初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
// 音效:WebAudio 合成,不需要资源文件。GDD §6:落子短音(音高随步号上升,12 后循环)、冲突闷音、过关和弦。
|
||||
// Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/conflict/cleared/reject。
|
||||
|
||||
export interface Sfx {
|
||||
unlock(): void;
|
||||
step(n: number): void;
|
||||
conflict(): void;
|
||||
cleared(): void;
|
||||
reject(): void;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export class WebAudioSfx implements Sfx {
|
||||
enabled = true;
|
||||
private ctx: AudioContext | null = null;
|
||||
|
||||
/** 必须在用户手势里调用一次,iOS 才放行 */
|
||||
unlock(): void {
|
||||
if (!this.ctx) {
|
||||
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
this.ctx = new AC();
|
||||
}
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume();
|
||||
}
|
||||
|
||||
private tone(freq: number, dur: number, type: OscillatorType, gain: number, when = 0): void {
|
||||
if (!this.enabled || !this.ctx) return;
|
||||
const ctx = this.ctx;
|
||||
const t0 = ctx.currentTime + when;
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g).connect(ctx.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.02);
|
||||
}
|
||||
|
||||
step(n: number): void {
|
||||
const semi = (n - 1) % 12;
|
||||
const f = 523.25 * Math.pow(2, semi / 12);
|
||||
this.tone(f, 0.09, 'sine', 0.18);
|
||||
}
|
||||
|
||||
conflict(): void {
|
||||
this.tone(130, 0.16, 'square', 0.08);
|
||||
}
|
||||
|
||||
cleared(): void {
|
||||
const base = 523.25;
|
||||
[0, 4, 7, 12].forEach((semi, i) => this.tone(base * Math.pow(2, semi / 12), 0.22, 'triangle', 0.16, i * 0.07));
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this.tone(220, 0.06, 'triangle', 0.06);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Painter 的 Canvas 2D 实现。Cocos 版本换成 Graphics + Label 即可,BoardView 不用改。
|
||||
import type { Rect } from '../src/layout';
|
||||
import type { Painter } from '../src/painter';
|
||||
|
||||
/**
|
||||
* 盘面字体栈,与 web/style.css 的 --font 完全一致(tests/theme.test.ts 校验)。
|
||||
* ui-rounded 让 iOS / macOS 直接拿到系统圆体;其余按平台回退到 PingFang / Noto。
|
||||
*/
|
||||
export const BOARD_FONT_FAMILY =
|
||||
'ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif';
|
||||
|
||||
export class CanvasPainter implements Painter {
|
||||
private readonly ctx: CanvasRenderingContext2D;
|
||||
|
||||
constructor(ctx: CanvasRenderingContext2D) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
private path(r: Rect, radius: number): void {
|
||||
const ctx = this.ctx;
|
||||
const rad = Math.max(0, Math.min(radius, r.w / 2, r.h / 2));
|
||||
ctx.beginPath();
|
||||
if (typeof ctx.roundRect === 'function') {
|
||||
ctx.roundRect(r.x, r.y, r.w, r.h, rad);
|
||||
} else {
|
||||
// 老 WebView 兜底
|
||||
const { x, y, w, h } = r;
|
||||
ctx.moveTo(x + rad, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, rad);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, rad);
|
||||
ctx.arcTo(x, y + h, x, y, rad);
|
||||
ctx.arcTo(x, y, x + w, y, rad);
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
fillRoundRect(r: Rect, radius: number, color: string, alpha = 1): void {
|
||||
if (alpha <= 0 || r.w <= 0 || r.h <= 0) return;
|
||||
const ctx = this.ctx;
|
||||
ctx.globalAlpha = Math.min(1, alpha);
|
||||
ctx.fillStyle = color;
|
||||
this.path(r, radius);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
strokeRoundRect(r: Rect, radius: number, color: string, lineWidth: number, alpha = 1): void {
|
||||
if (alpha <= 0) return;
|
||||
const ctx = this.ctx;
|
||||
ctx.globalAlpha = Math.min(1, alpha);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lineWidth;
|
||||
this.path(r, radius);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
fillCircle(cx: number, cy: number, radius: number, color: string, alpha = 1): void {
|
||||
if (alpha <= 0) return;
|
||||
const ctx = this.ctx;
|
||||
ctx.globalAlpha = Math.min(1, alpha);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** 数字 800、汉字 700(docs/design.md 盘面字阶);不加粗时 500 */
|
||||
text(s: string, cx: number, cy: number, size: number, color: string, bold = false, alpha = 1): void {
|
||||
if (alpha <= 0) return;
|
||||
const ctx = this.ctx;
|
||||
ctx.globalAlpha = Math.min(1, alpha);
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${bold ? (/^\d+$/.test(s) ? '800' : '700') : '500'} ${size}px ${BOARD_FONT_FAMILY}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(s, cx, cy + size * 0.04);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>数字一笔</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 选关 -->
|
||||
<section id="screen-select" class="screen">
|
||||
<header class="select-header">
|
||||
<h1>数字一笔</h1>
|
||||
<p class="sub">按数字顺序,一笔走遍每一格</p>
|
||||
<div class="overall" aria-label="总进度">
|
||||
<div class="overall-bar"><i id="overall-fill"></i></div>
|
||||
<span id="overall-text" class="overall-text">0 / 0</span>
|
||||
</div>
|
||||
</header>
|
||||
<div id="pack-list" class="pack-list"></div>
|
||||
<button id="btn-continue" class="primary">继续</button>
|
||||
<footer class="select-footer">
|
||||
<button id="btn-sound" class="chip">音效</button>
|
||||
<button id="btn-haptic" class="chip">振动</button>
|
||||
<button id="btn-reset-progress" class="chip danger">重置进度</button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- 对局 -->
|
||||
<section id="screen-play" class="screen hidden">
|
||||
<header class="topbar">
|
||||
<button id="btn-back" class="icon" aria-label="返回选关">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<div class="level-info">
|
||||
<div id="level-title" class="level-title">1-1</div>
|
||||
<div id="level-sub" class="level-sub">入门 · 第 1 关</div>
|
||||
</div>
|
||||
<div id="remaining" class="remaining" aria-live="polite" aria-label="剩余格数">
|
||||
<svg class="remaining-icon tile" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg>
|
||||
<svg class="remaining-icon check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span id="remaining-n">0</span>
|
||||
</div>
|
||||
</header>
|
||||
<div id="level-progress" class="level-progress" aria-hidden="true"></div>
|
||||
<div id="caption" class="caption"></div>
|
||||
<div id="board-wrap" class="board-wrap">
|
||||
<canvas id="board"></canvas>
|
||||
</div>
|
||||
<p class="gesture-help">按住滑动连线 · 点已走格回退</p>
|
||||
<footer class="bottombar">
|
||||
<button id="btn-restart" class="action">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.5-5.8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>重开</span>
|
||||
</button>
|
||||
<button id="btn-hint" class="action">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2.2h5c.1-1 .5-1.7 1.1-2.2A6 6 0 0 0 12 3z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>补数字</span>
|
||||
</button>
|
||||
<button id="btn-route-hint" class="action">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>指路</span>
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
</div>
|
||||
<script src="dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,604 @@
|
||||
// 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();
|
||||
@@ -0,0 +1,646 @@
|
||||
/* 数字一笔 · Web 壳样式。令牌与组件规格见仓库 docs/design.md(改令牌先改文档,再同步 src/theme.ts)。 */
|
||||
|
||||
:root {
|
||||
/* ---- 颜色 ---- */
|
||||
--bg: #ffffff;
|
||||
--bg-app: #f2f2f6;
|
||||
--surface-2: #f3f3f7;
|
||||
--cell: #e6e7ec;
|
||||
--line: #e1e2ea;
|
||||
--ink: #23243a;
|
||||
--ink-2: #6b6d80;
|
||||
--ink-3: #a3a5b5;
|
||||
--primary: #6558f5;
|
||||
--primary-bright: #7d72ff;
|
||||
--primary-soft: #eceafe;
|
||||
--on-primary: #ffffff;
|
||||
--danger: #e5484d;
|
||||
--danger-soft: #fdecec;
|
||||
--hint: #f5a524;
|
||||
--hint-soft: #fef3dd;
|
||||
--clue-ring: #23243a;
|
||||
--overlay: rgba(35, 36, 58, 0.92);
|
||||
--shadow-primary: 0 6px 18px rgba(101, 88, 245, 0.28);
|
||||
--shadow-primary-pressed: 0 3px 10px rgba(101, 88, 245, 0.22);
|
||||
--shadow-frame: 0 20px 60px rgba(20, 20, 40, 0.14);
|
||||
|
||||
/* ---- 字体 ---- */
|
||||
--font: ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--fs-display: 34px;
|
||||
--fs-title: 22px;
|
||||
--fs-heading: 17px;
|
||||
--fs-body: 15px;
|
||||
--fs-label: 14px;
|
||||
--fs-caption: 13px;
|
||||
--fs-micro: 12px;
|
||||
|
||||
/* ---- 圆角 ---- */
|
||||
--r-sm: 10px;
|
||||
--r-md: 14px;
|
||||
--r-lg: 18px;
|
||||
--r-xl: 24px;
|
||||
--r-pill: 999px;
|
||||
|
||||
/* ---- 间距(4pt 网格) ---- */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-7: 28px;
|
||||
|
||||
/* ---- 动效 ---- */
|
||||
--t-fast: 120ms;
|
||||
--t-base: 200ms;
|
||||
--t-slow: 320ms;
|
||||
--t-hold: 500ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* ---- 尺寸 ---- */
|
||||
--tap: 44px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg-app);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.3;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: calc(var(--safe-top) + var(--sp-3)) calc(var(--safe-right) + var(--sp-5)) calc(var(--safe-bottom) + var(--sp-4))
|
||||
calc(var(--safe-left) + var(--sp-5));
|
||||
}
|
||||
|
||||
.screen.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen.enter {
|
||||
animation: screen-in var(--t-base) var(--ease-out) both;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
-webkit-touch-callout: none;
|
||||
transition: transform var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
|
||||
box-shadow var(--t-fast) var(--ease-out), opacity var(--t-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 3px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 选关 ---------- */
|
||||
|
||||
.select-header {
|
||||
padding: var(--sp-7) var(--sp-1) var(--sp-2);
|
||||
}
|
||||
|
||||
.select-header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-display);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.select-header .sub {
|
||||
margin: var(--sp-2) 0 0;
|
||||
color: var(--ink-2);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.overall {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.overall-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--cell);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overall-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--primary);
|
||||
transition: width var(--t-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.overall-text {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
min-width: 52px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pack-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--sp-3);
|
||||
padding-bottom: var(--sp-5);
|
||||
/* 继续按钮固定在底部,包列表自己滚;底部 20px 渐隐,避免被按钮“切断” */
|
||||
min-height: 0;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 20px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 20px), transparent);
|
||||
}
|
||||
|
||||
.pack {
|
||||
margin-top: var(--sp-7);
|
||||
}
|
||||
|
||||
.pack-list > .pack:first-child {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.pack-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--sp-1) var(--sp-3);
|
||||
font-size: var(--fs-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pack-progress {
|
||||
color: var(--ink-3);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pack.done .pack-progress {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.level-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.level-tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--cell);
|
||||
color: var(--ink);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.level-tile:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.level-tile.cleared {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
/* 已过关:右上角一个小勾(纯 CSS 画,不依赖字体里的 ✓) */
|
||||
.level-tile.cleared::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 6px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid rgba(255, 255, 255, 0.9);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.level-tile.current {
|
||||
box-shadow: inset 0 0 0 3px var(--primary);
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
animation: breathe 2.4s var(--ease-in-out) infinite;
|
||||
}
|
||||
|
||||
.level-tile.locked {
|
||||
color: var(--ink-3);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.level-tile.locked::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--ink-3);
|
||||
border-radius: 3px;
|
||||
border-top-width: 3px;
|
||||
}
|
||||
|
||||
.level-tile.locked span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.level-tile.shake {
|
||||
animation: shake var(--t-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.primary {
|
||||
margin-top: auto;
|
||||
height: 54px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: var(--fs-heading);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.primary:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: var(--shadow-primary-pressed);
|
||||
}
|
||||
|
||||
.select-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 34px;
|
||||
padding: 0 var(--sp-4);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip.on {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chip.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ---------- 对局 ---------- */
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.topbar .icon {
|
||||
justify-self: start;
|
||||
width: var(--tap);
|
||||
height: var(--tap);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-2);
|
||||
border-radius: var(--r-pill);
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.topbar .icon:active {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
text-align: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.level-title {
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.level-sub {
|
||||
margin-top: 3px;
|
||||
color: var(--ink-3);
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remaining {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
padding: 0 var(--sp-3) 0 10px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 700;
|
||||
min-width: 52px;
|
||||
justify-content: center;
|
||||
transition: background-color var(--t-base) var(--ease-out), color var(--t-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.remaining-icon {
|
||||
color: var(--ink-3);
|
||||
transition: color var(--t-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.remaining-icon.check {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.remaining.bump {
|
||||
animation: bump var(--t-base) var(--ease-spring);
|
||||
}
|
||||
|
||||
.remaining.done {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.remaining.done .remaining-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.remaining.done .remaining-icon.tile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.remaining.done .remaining-icon.check {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 本包进度条:每关一段。灰 = 未到,淡紫 = 已过,实紫 = 当前 */
|
||||
.level-progress {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 4px;
|
||||
margin: var(--sp-2) var(--sp-1) 0;
|
||||
}
|
||||
|
||||
.level-progress i {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--cell);
|
||||
transition: background-color var(--t-slow) var(--ease-out), opacity var(--t-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.level-progress i.cleared {
|
||||
background: var(--primary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.level-progress i.current {
|
||||
background: var(--primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.caption {
|
||||
height: 36px;
|
||||
margin-top: var(--sp-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-2);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition: opacity var(--t-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.caption:empty {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#board {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bottombar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.gesture-help {
|
||||
margin: 0;
|
||||
padding-top: var(--sp-2);
|
||||
color: var(--ink-2);
|
||||
font-size: var(--fs-caption);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action {
|
||||
flex: 1;
|
||||
max-width: 170px;
|
||||
height: 56px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.action svg {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.action:active {
|
||||
background: var(--cell);
|
||||
}
|
||||
|
||||
.action.pulse {
|
||||
animation: pulse 0.9s var(--ease-in-out) infinite;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(var(--safe-bottom) + 128px);
|
||||
transform: translate(-50%, 8px);
|
||||
padding: 10px var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--overlay);
|
||||
color: #fff;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 40px);
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-out);
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
/* ---------- 动效 ---------- */
|
||||
|
||||
@keyframes screen-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(245, 165, 36, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgba(245, 165, 36, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.14);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 3px var(--primary), 0 0 0 0 rgba(101, 88, 245, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 0 3px var(--primary), 0 0 0 6px rgba(101, 88, 245, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
body {
|
||||
padding: var(--sp-6) 0;
|
||||
}
|
||||
#app {
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow-frame);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user