feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user