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 $ = (id: string): T => document.getElementById(id) as T; const icon = (name: string) => ``; const format = (n: number) => Math.round(n).toLocaleString('en-US'); const canvas = $('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 = `${title}${subtitle}`; 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 => `${n}`).join(''); $('queue').setAttribute('aria-label', `接下来掉落 ${game.queue.slice(0, 3).join('、')}`); $('undo').disabled = !game.canUndo || !!animation; $('hint').disabled = !!animation; $('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 ? `${b.n}` : '…').join('·') + (result ? `→${result.stages[result.stages.length - 1].created.n}${result.stages.length > 1 ? `${result.stages.length} 连消` : ''}+${format(result.score)} 起` : `再连 ${Math.max(0, 3 - ids.length)} 颗`); } 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 = ``; function showHelp(): void { openSheet(`
A LITTLE POP, A LOT OF JOY

一划,就连起来

${closeButton}
${[1, 2, 3, 4].map((n, i) => `${i ? `${i === 3 ? '→' : '·'}` : ''}${n}`).join('')}
1

顺号连,同号也能连。
连接至少 3 颗相邻气球:1→2→3、3→2→1,或 2→2→2。顺号和同号不能混在一条里。

2

松手变大,自动接着合成。
新数字是最大数 +1,留在连线末端。周围或新掉落的气球能接上,就自动连消,最多 3 连!

3

攒满彩虹,双倍快乐。
长链和连消积攒能量,满 10 格后,接下来 5 次合成双倍得分,最多 4 连消。没有倒计时。

4

合成 10,让它飞向天空。
10 号气球自动放飞,奖励 10,000 分,腾出空间继续玩。没有关卡和步数限制,提示、撤销、吹口气都免费。

`,); } function showRecords(restart = false): void { save(); openSheet(`
YOUR BALLOON SKY

${restart ? '这一局,飞得不错' : '你的放飞记录'}

${closeButton}
${format(game.stats.score)}本局得分 · 最佳 ${format(record.bestScore)}
${game.stats.bestCombo}本局最高连消
${game.stats.reactions}已完成合成
${game.stats.flown}已放飞气球
${[4, 6, 8, 10].map(n => `
${n}${record.bestNumber >= n ? '已点亮' : '等你来连'}
`).join('')}

最高 ${record.bestCombo} 连消 · 单局最多放飞 ${record.bestFlown} 颗
想继续就继续,想重来就换一片天空。

${saveAvailable ? '' : '

浏览器未允许保存,记录暂存本次会话。

'}`); } 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('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('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 } });