From 2a4a327bf7faba1d012eb42f23940a2281b69291 Mon Sep 17 00:00:00 2001 From: kimsongwu Date: Mon, 27 Jul 2026 16:32:41 +0800 Subject: [PATCH] feat: create zero-dependency echo loop game --- README.md | 47 +++ index.html | 961 +++++++++++++++++++++++++++++++++++++++++++++++ tests/verify.mjs | 78 ++++ 3 files changed, 1086 insertions(+) create mode 100644 README.md create mode 100644 index.html create mode 100644 tests/verify.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa6587c --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# ECHO//LOOP + +ECHO//LOOP 是一款零依赖的时间回声解谜游戏。 + +你无法同时站在两个地方,但你可以把过去留在那里。玩家的每次移动都会成为一段时间线;点击“封存回声”后,这段路线会从起点自动重播。回声站在对应的绿色节点上时,会给同色闸门供能。你需要安排多段过去,让现在穿过一扇扇只在正确时刻打开的门。 + +## 运行 + +直接用浏览器打开 `index.html` 即可运行,不需要安装依赖、不需要构建、不需要服务端。 + +也可以在仓库目录运行: + +```bash +python3 -m http.server 8080 +``` + +然后访问 `http://localhost:8080/`。 + +## 操作 + +- 方向键或 `WASD`:移动 +- `Space`:原地等待一个脉冲 +- `Enter`:封存当前路线为回声 +- `Backspace`:撤销当前路线最后一步 +- `R`:重置当前实验 +- 移动端可使用棋盘下方的方向按钮 + +## 特点 + +- 纯 HTML / CSS / JavaScript,零运行时依赖 +- 4 个逐步升级的实验关卡 +- 回声重放、节点供能、时间闸门和空拍机制 +- `localStorage` 保存本机解锁进度 +- 响应式布局与触屏控制 +- 无后端、无数据库、无外部资源请求 + +## 测试 + +```bash +node tests/verify.mjs +``` + +测试不安装第三方包,会验证真实 `index.html` 的语法、地图结构、自包含性和首关规则数据。 + +## 许可证 + +MIT diff --git a/index.html b/index.html new file mode 100644 index 0000000..e416e7f --- /dev/null +++ b/index.html @@ -0,0 +1,961 @@ + + + + + + + + ECHO//LOOP | 时间回声实验 + + + + + +
+
+
+ +
+

CHRONO LAB / 09

+

ECHO//LOOP

+
+
+
当前时间线实验 01 / 04
+
+ +
+
+
+
+

实验 01 · 初次接触

+

把一秒钟留在原地

+

让你的旧路线成为新世界的开关。

+
+
+ + +
+
+ +
+
LIVE MEMORY ARRAY
● RECORDINGPULSE 00
+
+
+
+
+
A · 0 个回声在线ARROWS / WASD 移动 · SPACE 空拍
+
+ +
+
COMMAND DECK每一步都会留下痕迹
+
+
+ + + + +
+
+ + + + +
+
+ + +
+

回声不会互相阻挡。
它们只会忠实地重复。

+
+
+
+ + +
+ +
+ 零依赖 · 状态只保存在本机浏览器 + + SOURCE / GITEA ↗ +
+
+ + +
+

时间回声协议

+

你无法同时站在两个地方,但你可以把过去留在那里。每次封存都会把当前路线变成一个从起点重播的回声。

+
    +
  1. 01用方向键、WASD 或棋盘下方的方向键移动。每一次按键都是一个脉冲,撞墙也会消耗时间。
  2. +
  3. 02站到绿色节点上,然后点击“封存回声”。下一条时间线开始时,回声会重走这条路线。
  4. +
  5. 03回声站在对应节点上时,同色闸门会打开。老回声会一直重复自己的路线,并不会互相挡路。
  6. +
  7. 04抵达金色出口即可完成实验。Backspace 撤销当前路线最后一步,R 重置实验,Space 原地等待一拍。
  8. +
+
+
+
+ + + + + diff --git a/tests/verify.mjs b/tests/verify.mjs new file mode 100644 index 0000000..dd807f1 --- /dev/null +++ b/tests/verify.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { readFile, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = new URL('..', import.meta.url); +const htmlPath = new URL('index.html', root); +const html = await readFile(htmlPath, 'utf8'); + +assert.match(html, //i, 'index.html must be a complete HTML document'); +assert.match(html, /const LEVELS = Object\.freeze\(\[/, 'level configuration must be present'); +assert.doesNotMatch(html, /(?:https?:)?\/\/(?:unpkg|cdn\.jsdelivr|cdnjs|fonts\.googleapis|fonts\.gstatic)/i, 'the game must not load external runtime dependencies'); +assert.doesNotMatch(html, /<(?:script|link|img)[^>]+(?:src|href)=['"]https?:/i, 'the game must be self-contained'); + +const scriptMatches = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/gi)]; +assert.equal(scriptMatches.length, 1, 'the game should have one inline script'); +const script = scriptMatches[0][1]; +const tempDir = await mkdtemp(join(tmpdir(), 'echo-loop-')); +const scriptPath = join(tempDir, 'game.mjs'); +await writeFile(scriptPath, script); +const syntax = spawnSync(process.execPath, ['--check', scriptPath], { encoding: 'utf8' }); +assert.equal(syntax.status, 0, `browser script has syntax errors:\n${syntax.stderr}`); + +const levelsMatch = script.match(/const LEVELS = Object\.freeze\((\[[\s\S]*?\])\);\s*\n\s*const state/); +assert.ok(levelsMatch, 'could not extract level definitions'); +const levels = Function(`"use strict"; return (${levelsMatch[1]});`)(); +assert.equal(levels.length, 4, 'the game must contain four experiments'); + +for (const [index, level] of levels.entries()) { + assert.ok(level.id && level.name && level.title, `level ${index + 1} must have identity metadata`); + const width = level.map[0].length; + const symbols = level.map.flatMap((row) => [...row]); + assert.ok(level.map.length >= 3 && width >= 7, `level ${index + 1} is too small to be meaningful`); + assert.ok(level.map.every((row) => row.length === width), `level ${index + 1} rows must have equal width`); + assert.equal(symbols.filter((symbol) => symbol === 'S').length, 1, `level ${index + 1} needs one start`); + assert.equal(symbols.filter((symbol) => symbol === 'G').length, 1, `level ${index + 1} needs one goal`); + const plates = new Set(symbols.filter((symbol) => /[a-z]/.test(symbol))); + const gates = new Set(symbols.filter((symbol) => /[A-Z]/.test(symbol) && symbol !== 'S' && symbol !== 'G').map((symbol) => symbol.toLowerCase())); + assert.ok(plates.size >= 1, `level ${index + 1} needs a memory node`); + assert.deepEqual([...plates].sort(), [...gates].sort(), `level ${index + 1} node/gate pairs must match`); +} + +function simulateFirstExperiment(level) { + const map = level.map; + const start = { x: 1, y: 1 }; + const plate = { x: 4, y: 1 }; + const gate = { x: 5, y: 1 }; + const goal = { x: 9, y: 1 }; + const echo = { ...start }; + const stepRight = (entity, allowGate) => { + const next = { x: entity.x + 1, y: entity.y }; + const tile = map[next.y][next.x]; + if (tile === '#' || (next.x === gate.x && !allowGate)) return false; + entity.x = next.x; + return true; + }; + for (let index = 0; index < 3; index += 1) assert.equal(stepRight(echo, false), true, 'the recorded route must reach the first node'); + assert.deepEqual(echo, plate, 'the first echo must remain on node A after recording'); + const player = { ...start }; + for (let index = 0; index < 8; index += 1) { + const passed = stepRight(player, echo.x === plate.x && echo.y === plate.y); + assert.equal(passed, true, `player pulse ${index + 1} should be legal`); + } + assert.deepEqual(player, goal, 'the player must reach the goal through the powered gate'); +} + +const first = levels[0]; +simulateFirstExperiment(first); + +assert.match(script, /function resetEchoPositions\(\)/, 'multi-echo timelines must reset every echo to the start'); +assert.match(script, /resetEchoPositions\(\);\s*state\.actions = \[\];/, 'recording a new echo must restart the existing echo fleet'); + +console.log('ECHO//LOOP verification passed'); +console.log(`- ${levels.length} level definitions validated`); +console.log('- self-contained resource checks passed'); +console.log('- inline JavaScript syntax check passed'); +console.log('- echo-powered gate simulation passed');