// 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 $ = (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 = $('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 = `${lv.index}`; 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();