Files
NumberSwipe_New/modes/01-number-stroke/web/main.ts
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

605 lines
21 KiB
TypeScript

// Web 原型入口:DOM / Canvas / PointerEvent / localStorage 的粘合层。
// 规则、输入语义、动效状态都在 ../src 里;这里只做“把浏览器事件喂给 src,把 src 的状态画到屏幕上”。
import pack1Json from '../levels/pack-1-tutorial.json';
import pack2Json from '../levels/pack-2-advanced.json';
import pack3Json from '../levels/pack-3-parity.json';
import type { GameEvent } from '../src/game';
import { Game } from '../src/game';
import { pickClueHint } from '../src/clue-hint';
import { PointerInput } from '../src/input';
import { fitBoard, hitTest, type BoardLayout } from '../src/layout';
import { loadPack, parsePack, type LoadedLevel } from '../src/levels';
import { Progress, type KeyValueStore } from '../src/progress';
import { hint as computeHint, uniqueSolution } from '../src/solver';
import { BoardView } from '../src/view';
import { WebAudioSfx } from './audio';
import { CanvasPainter } from './canvas-painter';
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
const el = document.getElementById(id);
if (!el) throw new Error(`#${id} 不存在`);
return el as T;
};
class LocalStore implements KeyValueStore {
get(k: string): string | null {
try {
return localStorage.getItem(k);
} catch {
return null;
}
}
set(k: string, v: string): void {
try {
localStorage.setItem(k, v);
} catch {
/* 私密模式等 */
}
}
remove(k: string): void {
try {
localStorage.removeItem(k);
} catch {
/* ignore */
}
}
}
interface Session {
level: LoadedLevel;
game: Game;
view: BoardView;
input: PointerInput;
solution: number[] | null;
layout: BoardLayout | null;
}
class App {
private readonly packs = [pack1Json, pack2Json, pack3Json].map((j) => parsePack(j));
private readonly packLevels = this.packs.map((p) => loadPack(p));
/** 全部关卡按包序拼平;解锁、继续、过关跳关都基于这个跨包线性序列 */
private readonly levels: LoadedLevel[] = this.packLevels.flat();
private readonly ids = this.levels.map((l) => l.def.id);
private readonly progress = new Progress(new LocalStore());
private readonly sfx = new WebAudioSfx();
private readonly devUnlock = /[?&]dev\b/.test(location.search);
/** 单文件试玩版开放选关,不改通关记录,也不需要开启开发模式。 */
private readonly unlockAll = this.devUnlock || document.documentElement.dataset.unlockAll === 'true';
/** 系统“减弱动态效果”:盘面去掉进场错落 / 弹簧 / 扫光,CSS 侧由 media query 处理 */
private readonly reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
private readonly canvas = $<HTMLCanvasElement>('board');
private readonly ctx = this.canvas.getContext('2d')!;
private readonly painter = new CanvasPainter(this.ctx);
private readonly boardWrap = $('board-wrap');
private session: Session | null = null;
private rafId = 0;
private lastT = 0;
private activePointer: number | null = null;
private touchPointer = false;
private lastPos = { x: 0, y: 0 };
private advanceTimer = 0;
private exitTimer = 0;
private toastTimer = 0;
private cssSize = { w: 0, h: 0 };
private lastRemaining = -1;
constructor() {
this.sfx.enabled = this.progress.prefs.sound;
this.bindUI();
new ResizeObserver(() => this.resize()).observe(this.boardWrap);
this.renderSelect();
this.showSelect();
if (this.devUnlock) {
// ?dev:解锁全部关卡,并暴露调试句柄给自动化测试
(window as unknown as { __numberStroke: App }).__numberStroke = this;
const requested = new URLSearchParams(location.search).get('level');
const i = requested === null ? -1 : this.ids.indexOf(requested);
if (i >= 0) this.startLevel(i);
}
}
/** 调试:某格视觉锚点中心的视口坐标(clientX/Y),用于自动化手势测试。异形格取形心最近单位 */
debugCellCenter(cellId: string): { x: number; y: number } | null {
const s = this.session;
if (!s || !s.layout) return null;
const cell = s.level.board.cellById(cellId);
const [ax, ay] = s.level.board.anchorCenter(cell.index);
const r = this.canvas.getBoundingClientRect();
return {
x: r.left + s.layout.originX + ax * s.layout.unit,
y: r.top + s.layout.originY + ay * s.layout.unit,
};
}
/** 调试:当前路径(格 id)、状态与唯一解 */
debugState(): { id: string; path: string[]; status: string; remaining: number; solution: string[] | null; conflicts: string[]; revealed: { cell: string; step: number }[] } | null {
const s = this.session;
if (!s) return null;
const ids = (p: number[]) => p.map((i) => s.level.board.cells[i].id);
return {
id: s.level.def.id,
path: ids(s.game.path),
status: s.game.status,
remaining: s.game.remaining,
solution: s.solution ? ids(s.solution) : null,
conflicts: ids(s.game.path.filter((i) => s.game.isConflict(i))),
revealed: [...s.game.revealedClues].map(([cell, step]) => ({ cell: s.level.board.cells[cell].id, step })),
};
}
// ---------- 屏幕切换 ----------
private showSelect(): void {
this.cancelGesture();
this.stopLoop();
window.clearTimeout(this.exitTimer);
$('screen-play').classList.add('hidden');
this.renderSelect();
this.enterScreen($('screen-select'));
}
private showPlay(): void {
$('screen-select').classList.add('hidden');
const play = $('screen-play');
// 关与关之间只有盘面自己的进场 / 退场,整屏淡入只在从选关进来时做一次
if (play.classList.contains('hidden')) this.enterScreen(play);
this.resize();
this.startLoop();
}
/** 整屏进场:淡入 + 上移 8px(--t-base)。动画结束后摘掉类,便于下次重放 */
private enterScreen(el: HTMLElement): void {
el.classList.remove('hidden');
el.classList.remove('enter');
void el.offsetWidth;
el.classList.add('enter');
el.addEventListener('animationend', () => el.classList.remove('enter'), { once: true });
}
// ---------- 选关 ----------
private renderSelect(): void {
const list = $('pack-list');
list.innerHTML = '';
const currentId = this.progress.firstUncleared(this.ids);
const clearedTotal = this.progress.clearedCount();
const allCleared = clearedTotal >= this.levels.length;
$('overall-fill').style.width = `${(100 * clearedTotal) / this.levels.length}%`;
$('overall-text').textContent = `${clearedTotal} / ${this.levels.length}`;
let gi = 0; // 跨包全局序号:包间线性解锁(2-1 需先过 1-10)
this.packs.forEach((pack, pi) => {
const levels = this.packLevels[pi];
const section = document.createElement('div');
section.className = 'pack';
const clearedInPack = levels.filter((l) => this.progress.isCleared(l.def.id)).length;
if (clearedInPack === levels.length) section.classList.add('done');
const title = document.createElement('div');
title.className = 'pack-title';
title.innerHTML = `<span></span><span class="pack-progress">${clearedInPack} / ${levels.length}</span>`;
title.firstElementChild!.textContent = pack.title;
const grid = document.createElement('div');
grid.className = 'level-grid';
for (const lv of levels) {
const i = gi++;
const id = lv.def.id;
const tile = document.createElement('button');
tile.className = 'level-tile';
tile.innerHTML = `<span>${lv.index}</span>`;
const unlocked = this.unlockAll || this.progress.isUnlocked(this.ids, i);
if (this.progress.isCleared(id)) tile.classList.add('cleared');
else if (!unlocked) tile.classList.add('locked');
if (id === currentId && !allCleared) tile.classList.add('current');
tile.addEventListener('click', () => {
this.sfx.unlock();
if (!unlocked) {
tile.classList.remove('shake');
void tile.offsetWidth;
tile.classList.add('shake');
return;
}
this.startLevel(i);
});
grid.appendChild(tile);
}
section.appendChild(title);
section.appendChild(grid);
list.appendChild(section);
});
const btn = $('btn-continue');
btn.textContent = allCleared ? `全部通关 · 重玩 ${this.ids[0]}` : `继续 · ${currentId}`;
this.syncPrefChips();
}
private syncPrefChips(): void {
$('btn-sound').classList.toggle('on', this.progress.prefs.sound);
$('btn-haptic').classList.toggle('on', this.progress.prefs.haptics);
}
// ---------- 对局 ----------
private startLevel(i: number): void {
this.cancelGesture();
window.clearTimeout(this.advanceTimer);
window.clearTimeout(this.exitTimer);
const level = this.levels[i];
const game = new Game(level.board);
const view = new BoardView(game, { reducedMotion: this.reducedMotion });
this.session = {
level,
game,
view,
input: new PointerInput(game),
solution: uniqueSolution(level.board),
layout: null,
};
this.progress.setCurrent(level.def.id);
const pi = this.packLevels.findIndex((levels) => levels.includes(level));
$('level-title').textContent = level.def.id;
$('level-sub').textContent = `${this.packs[pi].title} · 第 ${level.index} 关`;
this.renderProgressStrip(pi, level.def.id);
$('caption').textContent = level.def.caption ?? '';
$('btn-hint').classList.remove('pulse');
$('btn-restart').classList.remove('pulse');
$('remaining').classList.remove('done', 'bump');
this.lastRemaining = -1;
this.setButtonsEnabled(true);
this.updateHud();
this.showPlay();
}
/** 顶栏下的本包进度条:每关一段,已过淡紫、当前实紫、未到灰 */
private renderProgressStrip(pi: number, currentId: string): void {
const strip = $('level-progress');
strip.innerHTML = '';
for (const lv of this.packLevels[pi]) {
const seg = document.createElement('i');
if (lv.def.id === currentId) seg.className = 'current';
else if (this.progress.isCleared(lv.def.id)) seg.className = 'cleared';
strip.appendChild(seg);
}
}
private currentIndex(): number {
return this.session ? this.levels.indexOf(this.session.level) : -1;
}
private nextLevel(): void {
const i = this.currentIndex();
if (i >= 0 && i + 1 < this.levels.length) this.startLevel(i + 1);
else {
this.showSelect();
this.toast(this.ids.every((id) => this.progress.isCleared(id)) ? '已通关全部 ' + this.levels.length + ' 关' : '本包已完成');
}
}
private updateHud(): void {
if (!this.session) return;
const n = this.session.game.remaining;
if (n === this.lastRemaining) return;
const pill = $('remaining');
if (this.session.game.isCleared) {
$('remaining-n').textContent = '完成';
pill.classList.add('done');
} else {
$('remaining-n').textContent = String(n);
}
// 数字变化时轻弹一下(--t-base,spring 缓动);开局第一次不弹
if (this.lastRemaining >= 0) {
pill.classList.remove('bump');
void pill.offsetWidth;
pill.classList.add('bump');
}
this.lastRemaining = n;
}
private setButtonsEnabled(on: boolean): void {
($('btn-hint') as HTMLButtonElement).disabled = !on;
($('btn-route-hint') as HTMLButtonElement).disabled = !on;
($('btn-restart') as HTMLButtonElement).disabled = !on;
}
private handleEvents(events: GameEvent[]): void {
const s = this.session;
if (!s) return;
for (const e of events) {
s.view.onEvent(e);
switch (e.type) {
case 'step':
if (e.conflict) {
this.sfx.conflict();
this.vibrate(40);
} else {
this.sfx.step(e.step);
this.vibrate(e.clueHit ? 18 : 8);
}
break;
case 'rejectStart':
this.sfx.reject();
this.vibrate(15);
break;
case 'deadEnd':
this.vibrate([10, 30, 10]);
break;
case 'rewound':
if (e.removed > 1) {
this.vibrate(12);
this.toast(`已回到第 ${s.game.stepOf(e.cell)} 步,可继续滑动`);
}
break;
case 'clueRevealed':
this.vibrate(18);
break;
case 'cleared':
this.onCleared();
break;
default:
break;
}
}
if (events.length) this.updateHud();
}
private onCleared(): void {
const s = this.session;
if (!s) return;
this.sfx.cleared();
this.vibrate([20, 40, 30]);
this.progress.markCleared(s.level.def.id);
this.setButtonsEnabled(false);
this.activePointer = null;
window.clearTimeout(this.advanceTimer);
window.clearTimeout(this.exitTimer);
// 扫光 → 停留 → 盘面淡出 → 下一关进场
this.exitTimer = window.setTimeout(() => s.view.beginExit(), s.view.clearedHoldEnd * 1000);
this.advanceTimer = window.setTimeout(() => this.nextLevel(), s.view.clearedDuration * 1000);
}
private restart(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
this.handleEvents(s.game.apply({ type: 'reset' }));
$('btn-restart').classList.remove('pulse');
}
private hint(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
if (!s.solution) {
this.toast('本关暂时无法补充数字');
return;
}
const h = pickClueHint(s.game, s.solution);
if (!h) {
this.toast('可补的数字已给出,试试「指路」');
return;
}
this.handleEvents(s.game.apply({ type: 'reveal', ...h }));
$('btn-restart').classList.remove('pulse');
this.toast(`补上了 ${h.step},第 ${h.step} 步要到这里`);
}
private routeHint(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
if (!s.solution) {
this.toast('本关没有唯一解(关卡数据有误)');
return;
}
const h = computeHint(s.solution, s.game.path);
s.view.showHint(h);
$('btn-restart').classList.remove('pulse');
switch (h.kind) {
case 'start':
this.toast('从这里开始');
break;
case 'next':
this.toast('下一步在这里');
break;
case 'backtrack':
this.toast(`点高亮格,直接退回 ${h.undoCount} 步`);
break;
case 'restart':
this.toast('起手位置不对,重开试试');
$('btn-restart').classList.add('pulse');
break;
case 'none':
break;
}
}
// ---------- 布局与渲染 ----------
private resize(): void {
const s = this.session;
const rect = this.boardWrap.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return;
const dpr = Math.min(3, window.devicePixelRatio || 1);
this.cssSize = { w: rect.width, h: rect.height };
this.canvas.width = Math.round(rect.width * dpr);
this.canvas.height = Math.round(rect.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (s) {
const cols = s.level.board.cols;
const rows = s.level.board.rows;
// 小盘不要铺满:格子最大约 100px;大盘按短边缩放
const maxUnit = cols * rows <= 6 ? 96 : 110;
s.layout = fitBoard(s.level.board, rect.width, rect.height, { padding: 10, maxUnit });
// 视觉中心略高于几何中心:整盘上提 3%,小盘不会显得往下坠
s.layout.originY = Math.max(10, s.layout.originY - rect.height * 0.03);
s.view.layout = s.layout;
s.view.invalidate();
}
this.render();
}
private startLoop(): void {
if (this.rafId) return;
this.lastT = performance.now();
const tick = (t: number): void => {
const dt = (t - this.lastT) / 1000;
this.lastT = t;
const s = this.session;
if (s) {
s.view.update(dt);
if (s.view.isAnimating) this.render();
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
private stopLoop(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
private render(): void {
const s = this.session;
this.ctx.clearRect(0, 0, this.cssSize.w, this.cssSize.h);
if (!s || !s.layout) return;
s.view.draw(this.painter);
}
// ---------- 输入 ----------
private pointerPos(e: PointerEvent): { x: number; y: number } {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
private cellAt(p: { x: number; y: number }): number {
const s = this.session;
if (!s || !s.layout) return -1;
return hitTest(s.level.board, s.layout, p.x, p.y, this.touchPointer ? 0.08 : 0.14);
}
private cancelGesture(): void {
this.session?.input.up();
this.session?.input.cancel();
const pointer = this.activePointer;
this.activePointer = null;
if (pointer !== null && this.canvas.hasPointerCapture(pointer)) this.canvas.releasePointerCapture(pointer);
}
private onPointerDown = (e: PointerEvent): void => {
if (!e.isPrimary || this.activePointer !== null) return;
if (!this.session || this.session.game.isCleared) return;
this.sfx.unlock();
this.activePointer = e.pointerId;
this.touchPointer = e.pointerType === 'touch';
try {
this.canvas.setPointerCapture(e.pointerId);
} catch {
/* 某些 WebView 不支持 */
}
const p = this.pointerPos(e);
this.lastPos = p;
this.handleEvents(this.session.input.down(this.cellAt(p)));
e.preventDefault();
};
private onPointerMove = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer || !this.session || !this.session.layout) return;
const p = this.pointerPos(e);
const target = this.cellAt(p);
// 直接落到历史格时,优先回退到目标,不把跳跃采样途中经过的空格误加进路径。
// 在同一个大格的不同部位滑动也不应穿出它、误加凹口里的邻格。
if (target >= 0 && this.session.game.isVisited(target)) {
this.lastPos = p;
this.handleEvents(this.session.input.move(target));
e.preventDefault();
return;
}
// 快速滑动时在两帧之间插值采样,避免跳过中间格
const step = this.session.layout.unit * 0.25;
const dx = p.x - this.lastPos.x;
const dy = p.y - this.lastPos.y;
const n = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
const events: GameEvent[] = [];
for (let i = 1; i <= n; i++) {
const q = { x: this.lastPos.x + (dx * i) / n, y: this.lastPos.y + (dy * i) / n };
events.push(...this.session.input.move(this.cellAt(q)));
}
this.lastPos = p;
this.handleEvents(events);
e.preventDefault();
};
private onPointerUp = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer) return;
this.activePointer = null;
if (this.session) this.handleEvents(this.session.input.up());
};
private vibrate(pattern: number | number[]): void {
if (!this.progress.prefs.haptics) return;
try {
navigator.vibrate?.(pattern);
} catch {
/* ignore */
}
}
// ---------- 提示条 ----------
private toast(msg: string): void {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
window.clearTimeout(this.toastTimer);
this.toastTimer = window.setTimeout(() => el.classList.remove('show'), 1800);
}
// ---------- 事件绑定 ----------
private bindUI(): void {
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
this.canvas.addEventListener('lostpointercapture', this.onPointerUp);
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
$('btn-back').addEventListener('click', () => {
window.clearTimeout(this.advanceTimer);
this.showSelect();
});
$('btn-restart').addEventListener('click', () => this.restart());
$('btn-hint').addEventListener('click', () => this.hint());
$('btn-route-hint').addEventListener('click', () => this.routeHint());
$('btn-continue').addEventListener('click', () => {
this.sfx.unlock();
const id = this.progress.clearedCount() >= this.levels.length ? this.ids[0] : this.progress.firstUncleared(this.ids);
this.startLevel(this.ids.indexOf(id));
});
$('btn-sound').addEventListener('click', () => {
this.progress.setPrefs({ sound: !this.progress.prefs.sound });
this.sfx.enabled = this.progress.prefs.sound;
if (this.sfx.enabled) {
this.sfx.unlock();
this.sfx.step(1);
}
this.syncPrefChips();
});
$('btn-haptic').addEventListener('click', () => {
this.progress.setPrefs({ haptics: !this.progress.prefs.haptics });
if (this.progress.prefs.haptics) this.vibrate(15);
this.syncPrefChips();
});
$('btn-reset-progress').addEventListener('click', () => {
if (window.confirm('清空本玩法的通关记录?')) {
this.progress.resetAll();
this.renderSelect();
}
});
window.addEventListener('resize', () => this.resize());
}
}
new App();