初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// 音效:WebAudio 合成,不需要资源文件。落子短音(音高随步号上升,12 后循环)、撞墙闷响、冲突、过关和弦。
// Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/wall/conflict/cleared/reject。
export interface Sfx {
unlock(): void;
step(n: number): void;
wall(): 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, slideTo?: number): 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);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
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);
}
/** 撞墙:短促的低频“咚” */
wall(): void {
this.tone(180, 0.09, 'square', 0.07, 0, 90);
}
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);
}
}
+80
View File
@@ -0,0 +1,80 @@
// Painter 的 Canvas 2D 实现。Cocos 版本换成 Graphics + Label 即可,HexBoardView 不用改。
import type { Point } from '../src/geom';
import type { Painter } from '../src/painter';
export class CanvasPainter implements Painter {
private readonly ctx: CanvasRenderingContext2D;
private readonly fontFamily =
'"SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif';
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
private path(pts: Point[]): void {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
}
fillPolygon(pts: Point[], color: string, alpha = 1): void {
if (alpha <= 0 || pts.length < 3) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
this.path(pts);
ctx.fill();
ctx.globalAlpha = 1;
}
strokePolygon(pts: Point[], color: string, lineWidth: number, alpha = 1): void {
if (alpha <= 0 || pts.length < 2) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineJoin = 'round';
this.path(pts);
ctx.stroke();
ctx.globalAlpha = 1;
}
line(x1: number, y1: number, x2: number, y2: number, width: number, color: string, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
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 = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
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 = alpha;
ctx.fillStyle = color;
ctx.font = `${bold ? '700' : '500'} ${size}px ${this.fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s, cx, cy + size * 0.04);
ctx.globalAlpha = 1;
}
}
+69
View File
@@ -0,0 +1,69 @@
<!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">
<div class="logo">
<span class="logo-hex"></span>
<span class="logo-hex on"></span>
<span class="logo-hex wall"></span>
</div>
<h1>六边一笔</h1>
<p class="sub">六边形上一笔走遍,条纹墙挡住那条路</p>
</header>
<div class="pack">
<div class="pack-title"><span id="pack-name">教学</span><span id="pack-progress" class="pack-progress"></span></div>
<div id="level-grid" class="level-grid"></div>
</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"><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 id="level-title" class="level-title">1-1</div>
<div id="remaining" class="remaining"><span class="hex-dot"></span><span id="remaining-n">0</span></div>
</header>
<div id="caption" class="caption"></div>
<div id="board-wrap" class="board-wrap">
<canvas id="board"></canvas>
</div>
<footer class="bottombar">
<button id="btn-restart" class="action">
<svg viewBox="0 0 24 24" width="22" height="22"><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"><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>
</footer>
<div id="toast" class="toast" role="status"></div>
</section>
</div>
<script src="dist/app.js"></script>
</body>
</html>
+490
View File
@@ -0,0 +1,490 @@
// 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();
+444
View File
@@ -0,0 +1,444 @@
:root {
--bg: #ffffff;
--ink: #20223a;
--ink-2: #686a80;
--ink-3: #a3a5b5;
--cell: #e4e6ee;
--primary: #4f5bd5;
--primary-soft: #e9ebfb;
--danger: #e5484d;
--wall: #1f1f27;
--wall-stripe: #ffc826;
--hex: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
--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: #f2f2f6;
color: var(--ink);
font-family: "SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
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) + 12px) calc(var(--safe-right) + 20px) calc(var(--safe-bottom) + 16px)
calc(var(--safe-left) + 20px);
}
.screen.hidden {
display: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
-webkit-touch-callout: none;
}
button:active {
transform: scale(0.97);
}
/* ---------- 选关 ---------- */
.select-header {
padding: 24px 4px 8px;
}
.logo {
display: flex;
gap: 4px;
margin-bottom: 14px;
}
.logo-hex {
width: 26px;
height: 30px;
background: var(--cell);
clip-path: var(--hex);
}
.logo-hex.on {
background: var(--primary);
}
.logo-hex.wall {
background: repeating-linear-gradient(135deg, var(--wall) 0 5px, var(--wall-stripe) 5px 10px);
}
.select-header h1 {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: 0.02em;
}
.select-header .sub {
margin: 8px 0 0;
color: var(--ink-2);
font-size: 15px;
}
.pack {
margin-top: 24px;
}
.pack-title {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 4px 12px;
font-size: 17px;
font-weight: 700;
}
.pack-progress {
color: var(--ink-3);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.level-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px 12px;
}
.pack-head {
grid-column: 1 / -1;
padding: 10px 4px 0;
color: var(--ink-3);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
}
.pack-head:first-child {
padding-top: 0;
}
.level-tile {
position: relative;
aspect-ratio: 0.9;
clip-path: var(--hex);
background: var(--cell);
color: var(--ink);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s ease;
}
.level-tile.cleared {
background: var(--primary);
color: #fff;
}
.level-tile.cleared::after {
content: "";
position: absolute;
right: 22%;
top: 22%;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.level-tile.current {
background: var(--primary-soft);
color: var(--primary);
}
.level-tile.current::before {
content: "";
position: absolute;
inset: 0;
clip-path: var(--hex);
background: var(--primary);
z-index: -1;
}
.level-tile.current span {
position: relative;
}
.level-tile.current {
isolation: isolate;
}
.level-tile.current::after {
content: "";
position: absolute;
inset: 3px;
clip-path: var(--hex);
background: var(--primary-soft);
z-index: -1;
}
.level-tile.locked {
color: var(--ink-3);
background: #f0f0f4;
}
.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 0.3s ease;
}
.primary {
margin-top: auto;
height: 54px;
border-radius: 16px;
background: var(--primary);
color: #fff;
font-size: 18px;
font-weight: 700;
box-shadow: 0 6px 18px rgba(79, 91, 213, 0.28);
}
.select-footer {
display: flex;
justify-content: center;
gap: 10px;
padding-top: 18px;
}
.chip {
height: 34px;
padding: 0 14px;
border-radius: 17px;
background: #f0f0f4;
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
}
.chip.on {
background: var(--primary-soft);
color: var(--primary);
}
.chip.danger {
color: var(--danger);
}
/* ---------- 对局 ---------- */
.topbar {
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
height: 48px;
}
.topbar .icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
border-radius: 22px;
}
.level-title {
text-align: center;
font-size: 20px;
font-weight: 800;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.remaining {
justify-self: end;
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 44px;
justify-content: flex-end;
}
.remaining .hex-dot {
width: 13px;
height: 15px;
clip-path: var(--hex);
background: var(--ink-3);
}
.caption {
height: 40px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
font-size: 15px;
text-align: center;
transition: opacity 0.2s;
}
.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: 14px;
padding-top: 12px;
}
.action {
flex: 1;
max-width: 170px;
height: 56px;
border-radius: 18px;
background: #f0f0f4;
color: var(--ink);
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.15s;
}
.action.pulse {
animation: pulse 0.9s ease infinite;
}
.action:disabled {
opacity: 0.4;
pointer-events: none;
}
.toast {
position: absolute;
left: 50%;
bottom: calc(var(--safe-bottom) + 92px);
transform: translate(-50%, 8px);
padding: 10px 16px;
border-radius: 12px;
background: rgba(32, 34, 58, 0.92);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s, transform 0.18s;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 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(255, 122, 26, 0);
}
50% {
box-shadow: 0 0 0 5px rgba(255, 122, 26, 0.35);
}
}
@media (min-width: 520px) {
body {
padding: 24px 0;
}
#app {
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
border-radius: 28px;
box-shadow: 0 20px 60px rgba(20, 20, 40, 0.14);
}
}