feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+111
View File
@@ -0,0 +1,111 @@
import { FLOWER_LEVEL, flowerTrackEmphasis } from '../../src/flower-study.js';
import { Game, available, trace, solveRotations } from '../../src/core.js';
import { TapQueue } from '../../src/tap-queue.js';
import { layout, hitCell } from '../../src/geometry.js';
import { CanvasPainter, drawScene } from '../../src/renderer.js';
import { TILE_ATLAS } from '../../src/sprites.js';
import { exitTiming } from '../../src/exit-animation.js';
import { MOTION } from '../../src/design-tokens.js';
import { unlockAudio, playSound } from '../../src/audio.js';
const $ = id => document.getElementById(id);
const canvas = $('board'), context = canvas.getContext('2d'), painter = new CanvasPainter(context);
const total = FLOWER_LEVEL.tiles.length;
const taps = new TapQueue();
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let game = new Game(FLOWER_LEVEL), view, width, height, animation = null, turning = null, hint = null, path = null, frame = 0;
function stats() {
const busy = !!(animation || turning), preview = $('pattern-only').checked;
$('remaining').textContent = game.tiles.length;
$('progress').textContent = game.won ? '一朵花,全部盛放' : `已放行 ${total - game.tiles.length} / ${total}${taps.ids.length ? ` · 待出发 ${taps.ids.length}` : ''}`;
for (const id of ['left', 'right', 'hint']) $(id).disabled = busy || game.won || preview;
$('undo').disabled = busy || !game.history.length || preview;
$('pattern-only').disabled = busy;
if (game.won && !busy) $('message').textContent = '十枚棋子都已离场,完整的重瓣花开了。';
}
function draw(now = performance.now()) {
cancelAnimationFrame(frame);
if (!view) return;
context.clearRect(0, 0, width, height);
const state = { hint, path, reduced, queued: taps.ids, trackEmphasis: $('all-tracks').checked ? null : flowerTrackEmphasis(game.orientation), secondaryTrackColor: '#c6c9c7' };
if (animation) {
const progress = Math.min(1, (now - animation.start) / animation.duration);
state.animation = { route: animation.route, progress };
if (progress >= 1) animation = null;
}
if (turning) {
const progress = Math.min(1, (now - turning.start) / turning.duration);
state.boardAngle = -turning.direction * Math.PI / 3 * Math.pow(1 - progress, 3);
if (progress >= 1) turning = null;
}
drawScene(painter, game.level, $('pattern-only').checked ? [] : game.tiles, view, state);
if (!animation && !turning && !$('pattern-only').checked) {
const next = taps.take(game.tiles);
if (next) { tap(next); return; }
}
stats();
if (animation || turning) frame = requestAnimationFrame(draw);
}
function resize() {
const rect = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 3);
width = rect.width; height = rect.height;
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
view = layout(game.level.cells, width, height); draw();
}
function clearEffects() { taps.clear(); cancelAnimationFrame(frame); animation = null; turning = null; hint = null; path = null; }
function tap(tile) {
if (game.won || $('pattern-only').checked) return;
if (animation || turning) {
if (taps.add(tile.id, animation?.route.tile.id)) draw();
return;
}
hint = null; path = null;
const result = game.tap(tile.id);
if (result.status === 'exit') {
animation = { route: result, start: performance.now(), duration: reduced ? 100 : exitTiming(result).duration };
$('message').textContent = result.steps.length > 2 ? '沿着内外花瓣和环带,绕到另一侧的出口。' : '先让开这个出口,花瓣里的长路就有了空间。';
playSound('exit');
} else {
taps.clear();
if (result.travelPoints) animation = { route: result, start: performance.now(), duration: reduced ? 150 : MOTION.collisionMs };
$('message').textContent = '出口还有一枚棋子。先给它让路,或转一下底盘再试。';
playSound('hit');
}
draw();
}
function rotate(direction) {
if (animation || turning || game.won || $('pattern-only').checked) return;
taps.clear();
const before = new Set(available(game.level, game.tiles).map(t => t.id));
game.rotate(direction); hint = null; path = null;
const opened = available(game.level, game.tiles).filter(t => !before.has(t.id));
turning = { direction, start: performance.now(), duration: reduced ? 100 : MOTION.rotationMs };
$('message').textContent = opened.length ? '花心换了一种连接,新的出口打开了。' : '花瓣仍然整齐,花心的连接已经换了方向。';
playSound('rotate'); draw();
}
canvas.addEventListener('pointerup', event => {
const rect = canvas.getBoundingClientRect(), cell = hitCell(game.level.cells, view, event.clientX - rect.left, event.clientY - rect.top);
const tile = cell && game.tiles.find(t => t.q === cell.q && t.r === cell.r);
if (tile) tap(tile);
});
$('left').onclick = () => rotate(-1);
$('right').onclick = () => rotate(1);
$('undo').onclick = () => { if (!animation && !turning && game.undo()) { clearEffects(); $('message').textContent = '回到上一步,再看看花瓣的出口。'; draw(); } };
$('reset').onclick = () => { clearEffects(); $('pattern-only').checked = false; game = new Game(FLOWER_LEVEL); $('message').textContent = '先放走朝外的两枚,给花瓣里的长路让位置。'; draw(); };
$('hint').onclick = () => {
const action = solveRotations({ ...game.level, tiles: game.tiles, moveLimit: game.moves }).actions[0];
if (!action) return;
if (action.type === 'rotate') $('message').textContent = `向${action.direction === 1 ? '右' : '左'}转一次,花心就会接上空出的出口。`;
else { hint = action.id; path = trace(game.level, game.tiles.find(t => t.id === hint), game.tiles); $('message').textContent = '这一枚的花径已经畅通,可以出发。'; }
draw();
};
$('all-tracks').onchange = () => draw();
$('pattern-only').onchange = () => {
if (animation || turning) $('pattern-only').checked = false;
hint = null; path = null; draw();
};
document.addEventListener('pointerdown', unlockAudio, { passive: true });
const atlas = new Image(); atlas.onload = () => { painter.atlas = atlas; draw(); }; atlas.src = '../../' + TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>花有出路 · HEX TRAILS 图案关测试</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main>
<header>
<a class="back" href="../../">← 返回主线</a>
<span class="badge">图案关 · TEST 01 · REV 02</span>
</header>
<section class="intro">
<p class="eyebrow">A FLOWER IN MOTION</p>
<h1>花有出路 <small>重瓣</small></h1>
<p>双层花瓣,三重环带。一朵由长路线编织的花。</p>
</section>
<div class="status"><span>待离场 <strong id="remaining">10</strong></span><span id="progress">已放行 0 / 10</span></div>
<div class="board-wrap"><canvas id="board" aria-label="双层十二瓣花形轨道棋盘,点击箭头棋子离场"></canvas></div>
<p class="message" id="message" role="status">先放走朝外的两枚,给花瓣里的长路让位置。</p>
<nav class="controls" aria-label="棋盘操作">
<button id="left" type="button">↶ <span>左转</span></button>
<button id="undo" type="button">↩ <span>撤销</span></button>
<button id="hint" type="button">✧ <span>提示</span></button>
<button id="reset" type="button">↻ <span>重来</span></button>
<button id="right" type="button">↷ <span>右转</span></button>
</nav>
<footer>
<label><input id="pattern-only" type="checkbox"> 暂隐棋子,看完整花纹</label>
<label><input id="all-tracks" type="checkbox"> 全部轨道等粗显示</label>
<p>花瓣与环带都能通行,交叉处沿原轨道前进。旋转底盘,棋子与箭头留在原位。</p>
</footer>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:#3c4d5b;background:#f1e7e5;font-synthesis:none}
*{box-sizing:border-box}
body{margin:0}
main{max-width:900px;margin:auto;padding:16px 28px 20px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.back{color:#526575;text-decoration:none;font-size:13px}
.badge{font-size:11px;letter-spacing:.08em;color:#7d5864;border:1px solid #d6bac2;border-radius:20px;padding:7px 12px}
.intro{text-align:center;margin:10px 0 12px}
.eyebrow{font-size:10px;letter-spacing:.24em;color:#8b6873}
h1{font-size:28px;letter-spacing:.15em;margin:7px 0 10px;font-weight:650}
.intro>p:last-child{font-size:13px;margin:0;color:#65717b}
.status{display:flex;align-items:center;justify-content:space-between;gap:12px;max-width:620px;margin:auto;font-size:12px;color:#65717b}
.status strong{font-size:24px;color:#3c4d5b;margin-left:6px}
#progress{font-size:11px}
.board-wrap{width:100%;height:clamp(400px,calc(100svh - 310px),740px);min-height:380px}
canvas{width:100%;height:100%;display:block;touch-action:manipulation}
.message{margin:4px 0 18px;text-align:center;font-size:13px;min-height:36px;line-height:1.6}
.controls{display:flex;justify-content:center;gap:14px}
button{font:24px system-ui;color:#526575;background:#fffaf5;border:1px solid #dfd4cf;border-radius:18px;min-width:74px;min-height:66px;padding:6px 12px;cursor:pointer;box-shadow:0 3px 0 #d7c8c5}
button span{display:block;font-size:11px}
button:hover{background:#fff}
button:active{transform:translateY(2px);box-shadow:0 1px 0 #d7c8c5}
button:disabled{opacity:.4;cursor:default}
button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #98708a;outline-offset:4px}
footer{text-align:center;margin:18px auto 0;max-width:560px;font-size:11px;color:#6b757e;line-height:1.7}
h1 small{font-size:13px;letter-spacing:.1em;font-weight:500;vertical-align:middle;border-left:1px solid #c9b0b8;margin-left:5px;padding-left:12px;color:#8b6873}
footer label{display:inline-flex;align-items:center;gap:7px;margin:0 8px;font-size:12px;color:#526575;cursor:pointer}
footer p{margin:8px 0 0}
input{accent-color:#8b6873}
@media(max-width:540px){main{padding:16px 14px 20px}.intro{margin:12px 0}h1{font-size:28px}.board-wrap{height:380px;min-height:0}.controls{gap:9px}button{min-width:57px;min-height:60px;padding:5px 9px}.message{font-size:12px;margin-bottom:12px}footer{margin-top:22px}}
@media(prefers-reduced-motion:reduce){button:active{transform:none}}