初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
export class Sound {
|
||||
enabled = true;
|
||||
private context?: AudioContext;
|
||||
unlock(): void {
|
||||
if (!this.enabled) return;
|
||||
try { this.context ||= new AudioContext(); if (this.context.state === 'suspended') void this.context.resume().catch(() => {}); } catch { /* 无音频设备仍能玩 */ }
|
||||
}
|
||||
note(n: number, merge = false): void {
|
||||
if (!this.enabled || !this.context) return;
|
||||
try {
|
||||
const c = this.context, time = c.currentTime, gain = c.createGain(), oscillator = c.createOscillator();
|
||||
oscillator.type = 'sine'; oscillator.frequency.value = 220 * Math.pow(2, (n + (merge ? 5 : 0)) / 12);
|
||||
gain.gain.setValueAtTime(0, time); gain.gain.linearRampToValueAtTime(0.09, time + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, time + (merge ? 0.32 : 0.13));
|
||||
oscillator.connect(gain); gain.connect(c.destination); oscillator.start(); oscillator.stop(time + 0.36);
|
||||
} catch { /* 音效不是游戏运行前提 */ }
|
||||
}
|
||||
pop(n: number, combo: number, fever: boolean): void {
|
||||
this.note(n + combo * 2 + (fever ? 3 : 0), true);
|
||||
if (combo > 1) window.setTimeout(() => this.note(n + combo * 2 + 5, true), 45);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Draw } from '../src/view';
|
||||
export function paint(ctx: CanvasRenderingContext2D, commands: Draw[]): void {
|
||||
for (const d of commands) {
|
||||
ctx.save(); ctx.globalAlpha = d.alpha ?? 1;
|
||||
ctx.fillStyle = d.fill || 'transparent'; ctx.strokeStyle = d.stroke || 'transparent';
|
||||
ctx.lineWidth = d.width || 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
if (d.type === 'circle') ctx.arc(d.x, d.y, Math.max(0, d.r), 0, Math.PI * 2);
|
||||
else if (d.type === 'ellipse') ctx.ellipse(d.x, d.y, d.rx, d.ry, 0, 0, Math.PI * 2);
|
||||
else if (d.type === 'path') {
|
||||
if (d.dash) ctx.setLineDash(d.dash);
|
||||
d.points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
|
||||
if (d.close) ctx.closePath();
|
||||
} else {
|
||||
ctx.font = `${d.weight || 600} ${d.size}px "Avenir Next", "Nunito", "PingFang SC", sans-serif`;
|
||||
ctx.textAlign = d.align || 'center'; ctx.textBaseline = 'middle';
|
||||
if (d.stroke) ctx.strokeText(d.text, d.x, d.y);
|
||||
ctx.fillText(d.text, d.x, d.y); ctx.restore(); continue;
|
||||
}
|
||||
if (d.fill) ctx.fill(); if (d.stroke) ctx.stroke(); ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#f3f9ff">
|
||||
<meta name="description" content="连连气球:顺号连,同号也能连。自动连消、彩虹双倍、明亮气球,轻轻一划一直玩。">
|
||||
<title>连连气球 · POP & LINK</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cellipse cx='32' cy='27' rx='22' ry='26' fill='%2379bafa'/%3E%3Cpath d='m32 50-5 8h10Z' fill='%2362a7eb'/%3E%3Cellipse cx='24' cy='14' rx='7' ry='3' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><symbol id="i-balloon" viewBox="0 0 24 24"><ellipse cx="12" cy="9" rx="6.5" ry="8"/><path d="m12 17-2 3h4l-2-3M12 20c-4 2 3 2 0 4M9 5l-1 2"/></symbol><symbol id="i-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z"/></symbol>
|
||||
<symbol id="i-leaf" viewBox="0 0 24 24"><path d="M12 21V11M12 14C3 15 3 7 3 5c6 0 10 3 9 9ZM12 10c0-6 4-8 9-8 0 6-3 10-9 10"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="2"/><rect x="14" y="4" width="6" height="6" rx="2"/><rect x="4" y="14" width="6" height="6" rx="2"/><rect x="14" y="14" width="6" height="6" rx="2"/></symbol>
|
||||
<symbol id="i-sound" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM15 8c3 2 3 6 0 8M18 5c5 4 5 10 0 14"/></symbol>
|
||||
<symbol id="i-muted" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM16 9l5 6m0-6-5 6"/></symbol>
|
||||
<symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9 9a3 3 0 0 1 6 0c0 2-3 2-3 5M12 17h.01"/></symbol>
|
||||
<symbol id="i-undo" viewBox="0 0 24 24"><path d="m8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3"/></symbol>
|
||||
<symbol id="i-hint" viewBox="0 0 24 24"><path d="M8 16c0-3-3-3-3-7a7 7 0 0 1 14 0c0 4-3 4-3 7H8ZM9 20h6M10 23h4M10 11l2 2 2-2M12 13v3"/></symbol>
|
||||
<symbol id="i-shuffle" viewBox="0 0 24 24"><path d="M3 6h3c5 0 6 12 11 12h4m-4-4 4 4-4 4M3 18h3c2 0 4-3 6-6s3-6 5-6h4m-4-4 4 4-4 4"/></symbol>
|
||||
<symbol id="i-restart" viewBox="0 0 24 24"><path d="M4 9a8 8 0 1 1 0 7M4 3v6h6"/></symbol>
|
||||
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M4 12h16m-6-6 6 6-6 6"/></symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
</defs></svg>
|
||||
<aside class="desktop-note" aria-hidden="true"><div class="floating-balloon b-one">1</div><div class="floating-balloon b-two">2</div><div class="floating-balloon b-three">3</div><p>POP. LINK. REPEAT.</p><h2>轻轻一划,<br>快乐就连起来了。</h2><span>让数字长大,让心情放飞。</span></aside>
|
||||
<main class="app" id="app">
|
||||
<header class="brand-bar">
|
||||
<div class="brand"><span class="brand-mark"><svg><use href="#i-balloon"/></svg></span><div><h1>连连气球</h1><span>POP & LINK</span></div></div>
|
||||
<div class="header-actions"><button class="icon-button" id="sound" aria-label="关闭音效"><svg><use href="#i-sound"/></svg></button><button class="icon-button" id="help" aria-label="玩法说明"><svg><use href="#i-help"/></svg></button></div>
|
||||
</header>
|
||||
<section class="scoreboard" aria-label="本局成绩">
|
||||
<div class="score-main"><span class="eyebrow">本局得分</span><strong id="score">0</strong><span class="best-line">最佳 <b id="best-score">0</b></span></div>
|
||||
<button class="collection" id="records" aria-label="查看放飞记录"><span class="best-balloon" id="peak">4</span><span>最大气球</span></button>
|
||||
</section>
|
||||
<section class="flow-bar" aria-label="补给和双倍能量">
|
||||
<div class="fever-block"><div class="fever-label"><span id="fever-label">彩虹能量</span><b id="fever-count">0 / 10</b></div><div class="fever-track" role="progressbar" id="fever-meter" aria-label="彩虹能量" aria-valuemin="0" aria-valuemax="10" aria-valuenow="0"><i id="fever-fill"></i></div></div>
|
||||
<div class="upcoming"><span>接下来</span><div id="queue" aria-label="接下来掉落的数字"></div></div>
|
||||
</section>
|
||||
<section class="stage" id="stage" aria-label="气球游乐场">
|
||||
<canvas id="board" aria-label="按住气球,连接至少三个连续数字或三个相同数字,再松手。新球会自动接着合成。" role="img"></canvas>
|
||||
<div class="welcome" id="welcome"><span>1 · 2 · 3 <i>或</i> 2 · 2 · 2</span><p>顺号连,同号也能连</p></div>
|
||||
<div class="combo-banner" id="combo-banner" aria-live="polite"></div>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<span class="stage-caption" id="stage-caption">合成 10,放飞一颗快乐</span>
|
||||
</section>
|
||||
<div class="chain-preview" id="chain-preview" aria-live="polite">连起 3 颗,看看能接出几连消</div>
|
||||
<nav class="tools" aria-label="游戏工具">
|
||||
<button id="undo" disabled><svg><use href="#i-undo"/></svg><span>撤一步</span></button>
|
||||
<button id="hint" class="hint-button"><svg><use href="#i-hint"/></svg><span>找一串</span></button>
|
||||
<button id="shuffle"><svg><use href="#i-shuffle"/></svg><span>吹口气</span></button>
|
||||
<button id="restart"><svg><use href="#i-restart"/></svg><span>新一局</span></button>
|
||||
</nav>
|
||||
<footer class="game-footer"><span id="footer-text">不计时,不限步,开心一直连。</span><span id="flight-count">已放飞 0 颗</span></footer>
|
||||
</main>
|
||||
<div class="modal-backdrop" id="modal" hidden><section class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title" tabindex="-1"></section></div>
|
||||
<noscript>请启用 JavaScript 来玩连连气球。</noscript>
|
||||
<script src="dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
import { WIDTH, HEIGHT, PALETTE } from '../src/config';
|
||||
import { FlowGame, FEVER_TARGET, type FlowResult } from '../src/flow';
|
||||
import { FlowAnimation } from '../src/flow-animation';
|
||||
import { canFlowAppend } from '../src/flow-rules';
|
||||
import { Gesture } from '../src/input';
|
||||
import { drawBoard } from '../src/view';
|
||||
import { loadRecord, saveRecord, recordRound } from '../src/record';
|
||||
import type { Reaction } from '../src/cascade';
|
||||
import type { Point } from '../src/types';
|
||||
import { paint } from './canvas-painter';
|
||||
import { Sound } from './audio';
|
||||
declare const __DEV__: boolean;
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
const icon = (name: string) => `<svg aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
||||
const format = (n: number) => Math.round(n).toLocaleString('en-US');
|
||||
const canvas = $<HTMLCanvasElement>('board'), ctx = canvas.getContext('2d')!;
|
||||
const modal = $('modal'), sheet = $('sheet');
|
||||
const storage = { read: (key: string) => localStorage.getItem(key), write: (key: string, value: string) => localStorage.setItem(key, value) };
|
||||
const record = loadRecord(storage), sound = new Sound(); sound.enabled = record.sound;
|
||||
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let game = new FlowGame(1234), gesture = new Gesture(() => game.balls, canFlowAppend);
|
||||
let hints = game.hint(), hintUntil = Infinity;
|
||||
let transform = { scale: 1, x: 0, y: 0, dpr: 1 };
|
||||
let animation: FlowAnimation | null = null, seenReaction = 0;
|
||||
let pointer: number | null = null, previousFocus: HTMLElement | null = null;
|
||||
let toastTimer = 0, bannerTimer = 0, previewKey = '', saveAvailable = true;
|
||||
let displayScore = 0, scoreTarget = 0, lastFrame = 0;
|
||||
|
||||
function save(): void { recordRound(record, game.stats); saveAvailable = saveRecord(storage, record); }
|
||||
function resize(): void {
|
||||
const r = canvas.getBoundingClientRect(), dpr = Math.min(3, devicePixelRatio || 1), scale = Math.min(r.width / WIDTH, r.height / HEIGHT);
|
||||
canvas.width = Math.round(r.width * dpr); canvas.height = Math.round(r.height * dpr);
|
||||
transform = { scale, x: (r.width - WIDTH * scale) / 2, y: (r.height - HEIGHT * scale) / 2, dpr };
|
||||
}
|
||||
function coordinates(e: PointerEvent): Point {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left - transform.x) / transform.scale, y: (e.clientY - r.top - transform.y) / transform.scale };
|
||||
}
|
||||
function cancel(): void {
|
||||
gesture.cancel(); const id = pointer; pointer = null;
|
||||
if (id !== null && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
|
||||
updatePreview();
|
||||
}
|
||||
function toast(message: string): void {
|
||||
clearTimeout(toastTimer); $('toast').textContent = message; $('toast').classList.add('show');
|
||||
toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 2100);
|
||||
}
|
||||
function banner(title: string, subtitle: string, rainbow = false): void {
|
||||
clearTimeout(bannerTimer); const el = $('combo-banner');
|
||||
el.className = `combo-banner${rainbow ? ' rainbow' : ''}`; el.innerHTML = `<strong>${title}</strong><span>${subtitle}</span>`;
|
||||
void el.offsetWidth; el.classList.add('show');
|
||||
bannerTimer = window.setTimeout(() => el.classList.remove('show'), 1100);
|
||||
}
|
||||
function updateSound(): void {
|
||||
$('sound').innerHTML = icon(sound.enabled ? 'sound' : 'muted');
|
||||
$('sound').setAttribute('aria-label', sound.enabled ? '关闭音效' : '开启音效'); $('sound').setAttribute('aria-pressed', String(sound.enabled));
|
||||
}
|
||||
function updateHUD(): void {
|
||||
$('app').dataset.mode = 'flow'; $('app').dataset.turns = String(game.stats.turns); $('app').dataset.animating = String(!!animation);
|
||||
$('app').classList.toggle('fever', game.fever);
|
||||
$('peak').textContent = String(game.stats.maxNumber);
|
||||
const p = PALETTE[(game.stats.maxNumber - 1) % PALETTE.length]; $('peak').style.backgroundColor = p.fill;
|
||||
$('best-score').textContent = format(record.bestScore);
|
||||
$('fever-label').textContent = game.fever ? '彩虹时刻 · 双倍得分' : '彩虹能量';
|
||||
$('fever-count').textContent = game.fever ? `剩 ${game.feverLeft} 次` : `${game.feverCharge} / ${FEVER_TARGET}`;
|
||||
$('fever-fill').style.width = `${game.fever ? game.feverLeft / 5 * 100 : game.feverCharge / FEVER_TARGET * 100}%`;
|
||||
$('fever-meter').setAttribute('aria-valuenow', String(game.fever ? game.feverLeft * 2 : game.feverCharge));
|
||||
$('fever-meter').setAttribute('aria-valuetext', game.fever ? `双倍得分,剩 ${game.feverLeft} 次合成` : `${game.feverCharge} / 10`);
|
||||
$('queue').innerHTML = game.queue.slice(0, 3).map(n => `<span class="next-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('');
|
||||
$('queue').setAttribute('aria-label', `接下来掉落 ${game.queue.slice(0, 3).join('、')}`);
|
||||
$<HTMLButtonElement>('undo').disabled = !game.canUndo || !!animation;
|
||||
$<HTMLButtonElement>('hint').disabled = !!animation; $<HTMLButtonElement>('shuffle').disabled = !!animation;
|
||||
$('welcome').classList.toggle('hidden', game.stats.turns > 0 || !!animation);
|
||||
$('welcome').setAttribute('aria-hidden', String(game.stats.turns > 0 || !!animation));
|
||||
$('flight-count').textContent = `已放飞 ${game.stats.flown} 颗`;
|
||||
$('footer-text').textContent = saveAvailable ? '不计时,不限步,开心一直连。' : '浏览器未允许保存,成绩暂存本次';
|
||||
$('stage-caption').textContent = game.fever ? '彩虹时刻 · 最多 4 连消' : '合成 10,放飞一颗快乐';
|
||||
updatePreview();
|
||||
}
|
||||
function updatePreview(): void {
|
||||
const ids = gesture.chain, key = `${ids.join(',')}/${game.stats.turns}/${!!animation}`;
|
||||
if (key === previewKey) return; previewKey = key;
|
||||
const el = $('chain-preview');
|
||||
if (!ids.length) {
|
||||
el.innerHTML = animation ? '啵!还有下一连…' : game.stats.turns < 2 ? '连起 3 颗,看看能接出几连消' : '顺号或同号都能连 · 新球还能自动接力';
|
||||
return;
|
||||
}
|
||||
const balls = ids.map(id => game.balls.find(b => b.id === id)!);
|
||||
const result = game.preview(ids), visible = balls.length > 5 ? [...balls.slice(0, 2), null, ...balls.slice(-2)] : balls;
|
||||
el.innerHTML = visible.map(b => b ? `<span class="mini-ball" style="background:${PALETTE[(b.n - 1) % PALETTE.length].fill}">${b.n}</span>` : '<span>…</span>').join('<span>·</span>') +
|
||||
(result ? `<span>→</span><b>${result.stages[result.stages.length - 1].created.n}</b>${result.stages.length > 1 ? `<span class="predict">${result.stages.length} 连消</span>` : ''}<span class="gain">+${format(result.score)} 起</span>` : `<span class="gain">再连 ${Math.max(0, 3 - ids.length)} 颗</span>`);
|
||||
}
|
||||
function feedback(stage: Reaction): void {
|
||||
const x = Math.max(65, Math.min($('stage').clientWidth - 65, transform.x + stage.created.x * transform.scale)), y = transform.y + stage.created.y * transform.scale;
|
||||
const el = document.createElement('span'); el.className = 'score-pop'; el.textContent = `+${format(stage.points)}`;
|
||||
el.style.left = `${x}px`; el.style.top = `${y - 16}px`; $('stage').append(el); setTimeout(() => el.remove(), 750);
|
||||
sound.pop(stage.created.n, stage.index, animation?.result.doubled || false);
|
||||
if (stage.index >= 2) banner(`${stage.index} 连消!`, stage.index >= 3 ? '一口气,停不下来' : '接上了,再长大一点');
|
||||
if (stage.created.n >= 10) banner('放飞啦!', '10 号气球 · 额外 +10,000', true);
|
||||
if (!reduced.matches) for (let i = 0; i < 10; i++) {
|
||||
const spark = document.createElement('i'); spark.className = 'spark'; spark.style.left = `${x}px`; spark.style.top = `${y}px`;
|
||||
const angle = i / 10 * Math.PI * 2;
|
||||
spark.style.setProperty('--dx', `${Math.cos(angle) * (30 + stage.index * 7)}px`);
|
||||
spark.style.setProperty('--dy', `${Math.sin(angle) * (30 + stage.index * 7)}px`);
|
||||
spark.style.setProperty('--spark-color', PALETTE[i % PALETTE.length].fill);
|
||||
$('stage').append(spark); setTimeout(() => spark.remove(), 700);
|
||||
}
|
||||
}
|
||||
function begin(result: FlowResult): void {
|
||||
hints = []; seenReaction = 0; animation = new FlowAnimation(result, performance.now());
|
||||
scoreTarget = game.stats.score - result.score;
|
||||
if (result.kind === 'breeze') toast('呼——换个位置,继续连。');
|
||||
updateHUD();
|
||||
if (reduced.matches) finish();
|
||||
}
|
||||
function finish(): void {
|
||||
const result = animation?.result; animation = null;
|
||||
scoreTarget = game.stats.score; displayScore = game.stats.score;
|
||||
save(); updateHUD();
|
||||
if (result?.feverStarted) banner('彩虹时刻!', '接下来 5 次合成 · 双倍得分', true);
|
||||
else if (result?.supply.breeze && result.kind !== 'breeze') toast('微风送来新气球,接着连。');
|
||||
}
|
||||
function commit(ids: number[]): void {
|
||||
const result = game.commit(ids);
|
||||
if (result) begin(result);
|
||||
else { if (ids.length) toast('至少连 3 颗顺号或同号气球。'); updatePreview(); }
|
||||
}
|
||||
function frame(now: number): void {
|
||||
const dt = Math.min(60, now - lastFrame || 16); lastFrame = now;
|
||||
let balls = game.balls;
|
||||
if (animation) {
|
||||
const sample = animation.sample(now);
|
||||
if (sample) {
|
||||
balls = sample;
|
||||
const reaction = animation.reaction;
|
||||
if (reaction && reaction.index > seenReaction) {
|
||||
for (const event of animation.result.stages.filter(s => s.index > seenReaction && s.index <= reaction.index)) { scoreTarget += event.points; feedback(event); }
|
||||
seenReaction = reaction.index;
|
||||
}
|
||||
} else finish();
|
||||
}
|
||||
displayScore += (scoreTarget - displayScore) * (1 - Math.exp(-dt / 95));
|
||||
if (Math.abs(displayScore - scoreTarget) < 1) displayScore = scoreTarget;
|
||||
const score = format(displayScore); if ($('score').textContent !== score) $('score').textContent = score;
|
||||
if (now > hintUntil) hints = [];
|
||||
const t = transform;
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.setTransform(t.dpr * t.scale, 0, 0, t.dpr * t.scale, t.dpr * t.x, t.dpr * t.y);
|
||||
paint(ctx, drawBoard({ balls, chain: animation ? animation.activePath : gesture.chain, hint: animation ? [] : hints, time: now / 1000, fever: game.fever, reducedMotion: reduced.matches }));
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function undo(): void {
|
||||
if (animation) return; cancel();
|
||||
if (game.undo()) { hints = []; displayScore = scoreTarget = game.stats.score; previewKey = ''; updateHUD(); toast('已撤回,补球和彩虹能量一起还原。'); }
|
||||
}
|
||||
function newRound(): void {
|
||||
cancel(); animation = null; clearTimeout(bannerTimer); clearTimeout(toastTimer);
|
||||
$('stage').querySelectorAll('.spark,.score-pop').forEach(el => el.remove()); $('combo-banner').classList.remove('show'); $('toast').classList.remove('show');
|
||||
game = new FlowGame(Date.now() >>> 0); gesture = new Gesture(() => game.balls, canFlowAppend);
|
||||
hints = game.hint(); hintUntil = performance.now() + 4500; displayScore = scoreTarget = 0; previewKey = '';
|
||||
closeSheet(); updateHUD();
|
||||
}
|
||||
function openSheet(html: string): void {
|
||||
cancel(); if (animation) finish();
|
||||
if (modal.hidden) previousFocus = document.activeElement as HTMLElement;
|
||||
sheet.innerHTML = html; modal.hidden = false; $('app').inert = true; sheet.focus();
|
||||
}
|
||||
function closeSheet(): void { modal.hidden = true; $('app').inert = false; previousFocus?.focus(); previousFocus = null; }
|
||||
const closeButton = `<button class="icon-button close" data-action="close" aria-label="关闭面板">${icon('close')}</button>`;
|
||||
function showHelp(): void {
|
||||
openSheet(`<div class="sheet-head"><div><span class="eyebrow">A LITTLE POP, A LOT OF JOY</span><h2 id="sheet-title">一划,就连起来</h2></div>${closeButton}</div><div class="help-chain">${[1, 2, 3, 4].map((n, i) => `${i ? `<span>${i === 3 ? '→' : '·'}</span>` : ''}<span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('')}</div><div class="help-rule"><span>1</span><p><b>顺号连,同号也能连。</b><br>连接至少 3 颗相邻气球:1→2→3、3→2→1,或 2→2→2。顺号和同号不能混在一条里。</p></div><div class="help-rule"><span>2</span><p><b>松手变大,自动接着合成。</b><br>新数字是最大数 +1,留在连线末端。周围或新掉落的气球能接上,就自动连消,最多 3 连!</p></div><div class="help-rule"><span>3</span><p><b>攒满彩虹,双倍快乐。</b><br>长链和连消积攒能量,满 10 格后,接下来 5 次合成双倍得分,最多 4 连消。没有倒计时。</p></div><div class="help-rule"><span>4</span><p><b>合成 10,让它飞向天空。</b><br>10 号气球自动放飞,奖励 10,000 分,腾出空间继续玩。没有关卡和步数限制,提示、撤销、吹口气都免费。</p></div><button class="primary" data-action="close">知道了,接着连 ${icon('arrow')}</button>`,);
|
||||
}
|
||||
function showRecords(restart = false): void {
|
||||
save();
|
||||
openSheet(`<div class="sheet-head"><div><span class="eyebrow">YOUR BALLOON SKY</span><h2 id="sheet-title">${restart ? '这一局,飞得不错' : '你的放飞记录'}</h2></div>${closeButton}</div><div class="record-hero"><strong>${format(game.stats.score)}</strong><span>本局得分 · 最佳 ${format(record.bestScore)}</span></div><div class="record-grid"><div><strong>${game.stats.bestCombo}</strong><span>本局最高连消</span></div><div><strong>${game.stats.reactions}</strong><span>已完成合成</span></div><div><strong>${game.stats.flown}</strong><span>已放飞气球</span></div></div><div class="sky-collection">${[4, 6, 8, 10].map(n => `<div class="sky-item ${record.bestNumber >= n ? 'earned' : ''}"><span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span><span>${record.bestNumber >= n ? '已点亮' : '等你来连'}</span></div>`).join('')}</div><p class="small-note">最高 ${record.bestCombo} 连消 · 单局最多放飞 ${record.bestFlown} 颗<br>想继续就继续,想重来就换一片天空。</p><button class="primary" data-action="${restart ? 'new' : 'close'}">${restart ? '开始新一局' : '回去接着连'} ${icon('arrow')}</button><button class="secondary" data-action="${restart ? 'close' : 'new'}">${restart ? '继续这一局' : '换一片天空 · 新一局'}</button>${saveAvailable ? '' : '<p class="save-note">浏览器未允许保存,记录暂存本次会话。</p>'}`);
|
||||
}
|
||||
canvas.addEventListener('pointerdown', e => {
|
||||
if (e.button !== 0 || !e.isPrimary || pointer !== null || animation || !modal.hidden) return;
|
||||
e.preventDefault(); sound.unlock(); pointer = e.pointerId; canvas.setPointerCapture(pointer); hints = [];
|
||||
gesture.down(coordinates(e)); updatePreview();
|
||||
if (gesture.chain.length) sound.note(game.balls.find(b => b.id === gesture.chain[0])!.n);
|
||||
});
|
||||
canvas.addEventListener('pointermove', e => {
|
||||
if (e.pointerId !== pointer || animation) return;
|
||||
const old = gesture.chain.join(','); gesture.move(coordinates(e));
|
||||
if (old !== gesture.chain.join(',')) { const n = game.balls.find(b => b.id === gesture.chain[gesture.chain.length - 1])?.n; if (n) sound.note(n); updatePreview(); }
|
||||
});
|
||||
canvas.addEventListener('pointerup', e => {
|
||||
if (e.pointerId !== pointer) return; gesture.move(coordinates(e)); const ids = gesture.up(); pointer = null;
|
||||
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); commit(ids);
|
||||
});
|
||||
canvas.addEventListener('pointercancel', e => { if (e.pointerId === pointer) cancel(); });
|
||||
canvas.addEventListener('lostpointercapture', e => { if (e.pointerId === pointer) cancel(); });
|
||||
window.addEventListener('blur', cancel); window.addEventListener('resize', () => { cancel(); resize(); });
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) cancel(); });
|
||||
new ResizeObserver(resize).observe($('stage'));
|
||||
$('sound').addEventListener('click', () => { sound.enabled = !sound.enabled; record.sound = sound.enabled; sound.unlock(); updateSound(); save(); });
|
||||
$('help').addEventListener('click', showHelp); $('records').addEventListener('click', () => showRecords());
|
||||
$('undo').addEventListener('click', undo); $('restart').addEventListener('click', () => showRecords(true));
|
||||
$('shuffle').addEventListener('click', () => { if (!animation) { cancel(); begin(game.shuffle()); } });
|
||||
$('hint').addEventListener('click', () => { if (animation) return; cancel(); hints = game.hint(); hintUntil = performance.now() + 7000; if (hints.length) toast('沿白色标记连起来,松手看它自动接力。'); else toast('吹口气,换个位置试试。'); });
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeSheet(); });
|
||||
sheet.addEventListener('click', e => { const action = (e.target as HTMLElement).closest<HTMLButtonElement>('button')?.dataset.action; if (action === 'close') closeSheet(); if (action === 'new') newRound(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!modal.hidden) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeSheet(); }
|
||||
if (e.key === 'Tab') { const buttons = [...sheet.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')], first = buttons[0], last = buttons[buttons.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === sheet)) { e.preventDefault(); last?.focus(); }
|
||||
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === sheet)) { e.preventDefault(); first?.focus(); } }
|
||||
} else if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
if (e.key.toLowerCase() === 'z') undo(); if (e.key.toLowerCase() === 'h') $('hint').click(); if (e.key === 'Escape') cancel();
|
||||
}
|
||||
});
|
||||
updateSound(); updateHUD(); resize(); requestAnimationFrame(frame);
|
||||
if (__DEV__) Object.assign(window, { __balloons: { snapshot: () => game.snapshot(), hint: () => game.hint(), transform: () => ({ ...transform }), animating: () => !!animation } });
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user