- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
// Web 原型入口:DOM / Canvas / PointerEvent / localStorage 的粘合层。
|
|
// 规则、输入语义、动效状态都在 ../src 里;这里只做“把浏览器事件喂给 src,把 src 的状态画到屏幕上”。
|
|
import pack1Json from '../levels/pack-1-tutorial.json';
|
|
import pack2Json from '../levels/pack-2-spinner.json';
|
|
import type { GameEvent } from '../src/game';
|
|
import { Game } from '../src/game';
|
|
import { PointerInput } from '../src/input';
|
|
import { cellCenter, fitBoard, hitTest, type HexLayout } 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 { HexBoardView } 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: HexBoardView;
|
|
input: PointerInput;
|
|
solution: number[] | null;
|
|
layout: HexLayout | null;
|
|
}
|
|
|
|
class App {
|
|
private readonly packs = [parsePack(pack1Json), parsePack(pack2Json)].filter((p) => p.levels.length > 0);
|
|
/** 所有包的关卡拼成一条线性序列,跨包线性解锁 */
|
|
private readonly levels: LoadedLevel[] = this.packs.flatMap((p) => loadPack(p));
|
|
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 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 lastPos = { x: 0, y: 0 };
|
|
private advanceTimer = 0;
|
|
private toastTimer = 0;
|
|
private cssSize = { w: 0, h: 0 };
|
|
|
|
constructor() {
|
|
this.sfx.enabled = this.progress.prefs.sound;
|
|
this.bindUI();
|
|
new ResizeObserver(() => this.resize()).observe(this.boardWrap);
|
|
this.renderSelect();
|
|
this.showSelect();
|
|
if (this.devUnlock) {
|
|
(window as unknown as { __hexWalls: App }).__hexWalls = this;
|
|
}
|
|
}
|
|
|
|
/** 调试:某格中心的视口坐标(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 r = this.canvas.getBoundingClientRect();
|
|
const c = cellCenter(s.level.board, cell.index, s.layout);
|
|
return { x: r.left + c.x, y: r.top + c.y };
|
|
}
|
|
|
|
/** 调试:当前路径(格 id)、状态与唯一解 */
|
|
debugState(): { id: string; path: string[]; status: string; remaining: number; solution: string[] | null } | 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,
|
|
};
|
|
}
|
|
|
|
/** 调试:直接进某关 */
|
|
debugStart(id: string): void {
|
|
const i = this.ids.indexOf(id);
|
|
if (i >= 0) this.startLevel(i);
|
|
}
|
|
|
|
// ---------- 屏幕切换 ----------
|
|
|
|
private showSelect(): void {
|
|
this.stopLoop();
|
|
$('screen-play').classList.add('hidden');
|
|
$('screen-select').classList.remove('hidden');
|
|
this.renderSelect();
|
|
}
|
|
|
|
private showPlay(): void {
|
|
$('screen-select').classList.add('hidden');
|
|
$('screen-play').classList.remove('hidden');
|
|
this.resize();
|
|
this.startLoop();
|
|
}
|
|
|
|
// ---------- 选关 ----------
|
|
|
|
private renderSelect(): void {
|
|
$('pack-name').textContent = this.packs.map((p) => p.title).join(' · ');
|
|
$('pack-progress').textContent = `${this.progress.clearedCount()} / ${this.levels.length}`;
|
|
const grid = $('level-grid');
|
|
grid.innerHTML = '';
|
|
const currentId = this.progress.firstUncleared(this.ids);
|
|
const allCleared = this.progress.clearedCount() >= this.levels.length;
|
|
let lastPack = -1;
|
|
this.levels.forEach((lv, i) => {
|
|
const id = lv.def.id;
|
|
if (lv.pack !== lastPack) {
|
|
lastPack = lv.pack;
|
|
const head = document.createElement('div');
|
|
head.className = 'pack-head';
|
|
head.textContent = this.packs.find((p) => p.pack === lv.pack)?.title ?? `包 ${lv.pack}`;
|
|
grid.appendChild(head);
|
|
}
|
|
const tile = document.createElement('button');
|
|
tile.className = 'level-tile';
|
|
tile.innerHTML = `<span>${lv.index}</span>`;
|
|
const unlocked = this.devUnlock || 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);
|
|
});
|
|
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 {
|
|
window.clearTimeout(this.advanceTimer);
|
|
const level = this.levels[i];
|
|
const game = new Game(level.board);
|
|
const view = new HexBoardView(game);
|
|
this.session = {
|
|
level,
|
|
game,
|
|
view,
|
|
input: new PointerInput(game),
|
|
solution: uniqueSolution(level.board),
|
|
layout: null,
|
|
};
|
|
this.progress.setCurrent(level.def.id);
|
|
$('level-title').textContent = level.def.id;
|
|
$('caption').textContent = level.def.caption ?? '';
|
|
$('btn-hint').classList.remove('pulse');
|
|
$('btn-restart').classList.remove('pulse');
|
|
this.setButtonsEnabled(true);
|
|
this.updateHud();
|
|
this.showPlay();
|
|
}
|
|
|
|
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.toastOnSelect('已通关全部 ' + this.levels.length + ' 关');
|
|
}
|
|
}
|
|
|
|
private updateHud(): void {
|
|
if (!this.session) return;
|
|
$('remaining-n').textContent = String(this.session.game.remaining);
|
|
}
|
|
|
|
private setButtonsEnabled(on: boolean): void {
|
|
($('btn-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 'wallHit':
|
|
this.sfx.wall();
|
|
this.vibrate(25);
|
|
break;
|
|
case 'rejectStart':
|
|
this.sfx.reject();
|
|
this.vibrate(15);
|
|
break;
|
|
case 'deadEnd':
|
|
this.vibrate([10, 30, 10]);
|
|
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);
|
|
this.advanceTimer = window.setTimeout(() => this.nextLevel(), s.view.clearedDuration * 1000);
|
|
}
|
|
|
|
private restart(): void {
|
|
const s = this.session;
|
|
if (!s || s.game.isCleared) return;
|
|
s.input.cancel();
|
|
this.activePointer = null;
|
|
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;
|
|
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) {
|
|
// 小盘不要铺满:六边形半径最大约 58px;大盘按短边缩放
|
|
const maxSize = s.level.board.n <= 6 ? 52 : 60;
|
|
s.layout = fitBoard(s.level.board, rect.width, rect.height, { padding: 12, maxSize });
|
|
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);
|
|
}
|
|
|
|
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;
|
|
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 step = this.session.layout.size * 0.4;
|
|
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 toastOnSelect(msg: string): void {
|
|
const el = $('pack-progress');
|
|
const old = el.textContent;
|
|
el.textContent = msg;
|
|
window.setTimeout(() => (el.textContent = old), 2200);
|
|
}
|
|
|
|
// ---------- 事件绑定 ----------
|
|
|
|
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-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();
|