初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -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 } });
|
||||
Reference in New Issue
Block a user