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