feat: create zero-dependency echo loop game
This commit is contained in:
@@ -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
|
||||
+961
@@ -0,0 +1,961 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#081019">
|
||||
<meta name="description" content="ECHO//LOOP 是一款零依赖的时间回声解谜游戏。">
|
||||
<title>ECHO//LOOP | 时间回声实验</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #dce7eb;
|
||||
--muted: #84949d;
|
||||
--dim: #52636d;
|
||||
--line: rgba(137, 177, 190, .18);
|
||||
--panel: rgba(8, 19, 28, .9);
|
||||
--panel-2: rgba(11, 27, 36, .94);
|
||||
--void: #050b11;
|
||||
--cyan: #6de3e0;
|
||||
--cyan-bright: #b4fff1;
|
||||
--coral: #ff8f79;
|
||||
--amber: #f4c866;
|
||||
--violet: #c0a7ff;
|
||||
--green: #9deda7;
|
||||
--cell: min(5.2vw, 54px);
|
||||
font-family: "Inter", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--void);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; background: var(--void); }
|
||||
body { margin: 0; min-height: 100vh; overflow-x: hidden; background: #050b11; }
|
||||
button { font: inherit; color: inherit; }
|
||||
button:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
|
||||
|
||||
#ambient-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.noise-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: .055;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.92' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.7'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.app-shell { position: relative; z-index: 2; width: min(1480px, 100%); margin: 0 auto; padding: 26px clamp(16px, 4vw, 60px) 22px; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-bottom: 28px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-orbit { display: grid; place-items: center; width: 46px; height: 46px; color: var(--cyan); border: 1px solid rgba(109, 227, 224, .7); border-radius: 50%; font-size: 31px; line-height: 1; transform: rotate(-18deg); box-shadow: 0 0 28px rgba(109, 227, 224, .13), inset 0 0 14px rgba(109, 227, 224, .12); }
|
||||
.brand-mark p, .panel-label, .frame-top, .deck-head, .level-index, .kicker, .eyebrow { margin: 0; color: var(--muted); font-size: 10px; font-weight: 760; letter-spacing: .18em; text-transform: uppercase; }
|
||||
.brand-mark h1 { margin: 2px 0 0; color: #f4fbfa; font-size: clamp(20px, 2.3vw, 30px); font-weight: 760; letter-spacing: .12em; line-height: 1; }
|
||||
.brand-mark h1 span { color: var(--coral); }
|
||||
.run-state { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
|
||||
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); box-shadow: 0 0 12px var(--coral); animation: breathe 1.8s ease-in-out infinite; }
|
||||
.state-dot.ready { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
|
||||
.divider { display: inline-block; width: 1px; height: 14px; margin: 0 5px; background: var(--line); }
|
||||
|
||||
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: clamp(22px, 4vw, 54px); padding: 36px 0 26px; }
|
||||
.play-column { min-width: 0; }
|
||||
.briefing { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; min-height: 118px; padding-bottom: 22px; }
|
||||
.kicker { color: var(--coral); }
|
||||
.briefing h2 { max-width: 720px; margin: 8px 0 7px; color: #effafa; font-size: clamp(28px, 4vw, 48px); font-weight: 650; letter-spacing: -.02em; line-height: 1.08; }
|
||||
.briefing .subtitle { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.7; }
|
||||
.briefing-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
|
||||
.quiet-button, .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 1px solid var(--line); border-radius: 5px; background: rgba(12, 25, 34, .62); color: var(--muted); cursor: pointer; font-size: 11px; transition: border-color .2s, color .2s, background .2s, transform .2s; }
|
||||
.quiet-button:hover, .icon-button:hover { border-color: rgba(109, 227, 224, .6); color: var(--cyan-bright); background: rgba(18, 43, 51, .84); transform: translateY(-1px); }
|
||||
|
||||
.game-frame { position: relative; overflow: hidden; border: 1px solid rgba(119, 170, 183, .32); background: rgba(4, 12, 18, .89); box-shadow: 0 28px 80px rgba(0,0,0,.25), 0 0 45px rgba(72, 180, 180, .05); }
|
||||
.game-frame::after { position: absolute; inset: 0; z-index: 4; pointer-events: none; content: ""; background: linear-gradient(90deg, transparent 49.8%, rgba(109, 227, 224, .025) 50%, transparent 50.2%), linear-gradient(transparent 49.8%, rgba(109, 227, 224, .025) 50%, transparent 50.2%); background-size: 42px 42px; mix-blend-mode: screen; }
|
||||
.frame-top, .frame-bottom { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 11px 15px; background: rgba(6, 18, 26, .95); }
|
||||
.frame-top { border-bottom: 1px solid var(--line); }
|
||||
.frame-top > div { display: flex; align-items: center; gap: 15px; }
|
||||
.live { color: var(--coral); }
|
||||
.live::first-letter { text-shadow: 0 0 8px var(--coral); }
|
||||
#turn-readout { color: var(--dim); letter-spacing: .08em; }
|
||||
.board-wrap { display: grid; place-items: center; min-height: 408px; padding: clamp(25px, 4.4vw, 58px) clamp(10px, 3.5vw, 46px); background: radial-gradient(circle at 52% 45%, rgba(23, 73, 77, .2), transparent 45%); }
|
||||
#board { display: grid; gap: 3px; width: min(100%, calc(var(--cell) * 19)); grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); aspect-ratio: var(--cols) / var(--rows); }
|
||||
.tile { position: relative; display: grid; place-items: center; min-width: 0; min-height: 0; aspect-ratio: 1; border: 1px solid rgba(99, 143, 153, .11); background: rgba(17, 31, 38, .63); color: var(--muted); font-size: clamp(11px, 1.5vw, 17px); user-select: none; }
|
||||
.tile.wall { border-color: rgba(15, 29, 36, .9); background: rgba(3, 11, 17, .9); }
|
||||
.tile.floor::before { position: absolute; inset: 5px; border: 1px solid rgba(103, 153, 160, .04); content: ""; }
|
||||
.tile.goal { border-color: rgba(244, 200, 102, .45); background: rgba(91, 68, 24, .32); color: var(--amber); }
|
||||
.tile.goal::before { position: absolute; inset: 22%; border: 1px solid currentColor; border-radius: 50%; box-shadow: 0 0 17px currentColor; content: ""; animation: goal-pulse 2s ease-in-out infinite; }
|
||||
.tile.plate { border-color: rgba(157, 237, 167, .42); background: rgba(45, 94, 60, .22); color: var(--green); }
|
||||
.tile.plate::before { position: absolute; inset: 24%; border: 1px solid currentColor; border-radius: 50%; content: ""; box-shadow: 0 0 13px rgba(157, 237, 167, .45); }
|
||||
.tile.gate { border-color: rgba(255, 143, 121, .5); background: rgba(92, 41, 38, .3); color: var(--coral); }
|
||||
.tile.gate::before { position: absolute; inset: 19%; border: 1px solid currentColor; border-radius: 2px; content: ""; box-shadow: 0 0 14px rgba(255, 143, 121, .28); }
|
||||
.tile.gate.open { border-color: rgba(109, 227, 224, .72); background: rgba(23, 92, 91, .3); color: var(--cyan); }
|
||||
.tile.gate.open::before { border-style: dashed; box-shadow: 0 0 19px rgba(109, 227, 224, .6); animation: gate-spin 4s linear infinite; }
|
||||
.tile.start { color: var(--cyan); }
|
||||
.tile.start::after { width: 27%; height: 27%; border: 1px solid currentColor; border-radius: 50%; content: ""; box-shadow: 0 0 11px currentColor; }
|
||||
.tile.current::after, .tile.echo::after { position: absolute; z-index: 2; width: 46%; height: 46%; border-radius: 50%; content: ""; }
|
||||
.tile.current::after { background: var(--cyan-bright); box-shadow: 0 0 0 3px rgba(109, 227, 224, .2), 0 0 24px var(--cyan); animation: player-pulse .8s ease-in-out infinite; }
|
||||
.tile.current::before { border-color: rgba(180, 255, 241, .54); }
|
||||
.tile.echo::after { background: var(--coral); box-shadow: 0 0 0 3px rgba(255, 143, 121, .14), 0 0 22px var(--coral); opacity: .88; animation: echo-pulse 1.25s ease-in-out infinite; }
|
||||
.tile.echo.echo-1::after { background: var(--violet); box-shadow: 0 0 0 3px rgba(192,167,255,.14), 0 0 22px var(--violet); }
|
||||
.tile.echo.echo-2::after { background: var(--amber); box-shadow: 0 0 0 3px rgba(244,200,102,.14), 0 0 22px var(--amber); }
|
||||
.tile.echo.echo-3::after { background: var(--green); box-shadow: 0 0 0 3px rgba(157,237,167,.14), 0 0 22px var(--green); }
|
||||
.tile.current.echo::after { background: var(--cyan-bright); box-shadow: 0 0 0 3px rgba(109,227,224,.2), 0 0 24px var(--cyan); }
|
||||
.tile .tile-code { position: absolute; z-index: 3; top: 3px; left: 4px; color: currentColor; font-size: 8px; font-weight: 800; opacity: .75; }
|
||||
.tile .agent-number { position: absolute; z-index: 4; right: 4px; bottom: 3px; color: var(--void); font-size: 8px; font-weight: 900; }
|
||||
.frame-bottom { color: var(--dim); border-top: 1px solid var(--line); font-size: 10px; letter-spacing: .11em; text-transform: uppercase; }
|
||||
#pulse-bar { color: var(--cyan); }
|
||||
.flash { position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; pointer-events: none; opacity: 0; background: rgba(109, 227, 224, .08); color: #e8fffb; font-size: clamp(19px, 3vw, 30px); font-weight: 700; letter-spacing: .08em; text-shadow: 0 0 18px var(--cyan); }
|
||||
.flash.show { animation: flash .7s ease both; }
|
||||
|
||||
.command-deck { margin-top: 14px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
|
||||
.deck-head { display: flex; align-items: center; justify-content: space-between; padding: 11px 2px; }
|
||||
#command-hint { color: var(--dim); letter-spacing: .03em; text-transform: none; }
|
||||
.control-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 3px 0 14px; }
|
||||
.key-cluster { display: grid; grid-template-columns: repeat(3, 42px); grid-template-rows: repeat(2, 38px); gap: 4px; }
|
||||
.key-cluster .key-button:first-child { grid-column: 2; }
|
||||
.key-button { display: grid; place-items: center; width: 42px; height: 38px; border: 1px solid rgba(119, 170, 183, .34); border-radius: 4px; background: rgba(12, 27, 35, .82); color: var(--cyan); cursor: pointer; font-size: 15px; transition: transform .12s, background .12s, color .12s; }
|
||||
.key-button:hover { background: rgba(32, 80, 83, .9); color: #fff; }
|
||||
.key-button:active, .key-button.pressed { transform: translateY(2px); background: var(--cyan); color: var(--void); }
|
||||
.deck-actions { display: flex; align-items: center; gap: 7px; }
|
||||
.deck-actions button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 0 13px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 700; letter-spacing: .06em; }
|
||||
.record-button { border: 1px solid rgba(255, 143, 121, .62); background: rgba(107, 46, 40, .42); color: #ffb5a5; }
|
||||
.record-button:hover { background: rgba(145, 58, 48, .65); color: white; }
|
||||
.undo-button { border: 1px solid var(--line); background: rgba(12, 25, 34, .65); color: var(--muted); }
|
||||
.undo-button:hover { color: var(--ink); border-color: rgba(220,231,235,.45); }
|
||||
.deck-note { margin: 0; color: var(--dim); font-size: 10px; line-height: 1.65; text-align: right; }
|
||||
.touch-controls { display: none; }
|
||||
|
||||
.side-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.panel { border: 1px solid var(--line); background: var(--panel); }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 15px; border-bottom: 1px solid var(--line); }
|
||||
.panel-label { color: var(--muted); }
|
||||
.panel-body { padding: 15px; }
|
||||
.mission-copy { margin: 0 0 17px; color: var(--ink); font-size: 13px; line-height: 1.7; }
|
||||
.objective-row { display: grid; grid-template-columns: 25px 1fr auto; align-items: center; gap: 9px; min-height: 48px; padding: 9px 0; border-top: 1px solid rgba(137,177,190,.1); }
|
||||
.objective-icon { display: grid; place-items: center; color: var(--cyan); font-size: 17px; }
|
||||
.objective-row strong { display: block; color: var(--ink); font-size: 12px; font-weight: 650; }
|
||||
.objective-row small { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; line-height: 1.35; }
|
||||
.objective-status { color: var(--dim); font-size: 10px; font-weight: 800; letter-spacing: .07em; }
|
||||
.objective-status.done { color: var(--green); }
|
||||
.objective-status.live { color: var(--amber); }
|
||||
|
||||
#echo-list { display: flex; flex-direction: column; gap: 9px; min-height: 47px; }
|
||||
.empty-bank { display: flex; align-items: center; gap: 9px; min-height: 47px; color: var(--dim); font-size: 11px; line-height: 1.5; }
|
||||
.empty-bank .empty-orbit { color: var(--dim); font-size: 23px; }
|
||||
.echo-entry { display: grid; grid-template-columns: 26px 1fr auto; align-items: center; gap: 9px; min-height: 42px; padding: 8px 9px; border-left: 2px solid var(--coral); background: rgba(45, 29, 34, .5); }
|
||||
.echo-entry.echo-1 { border-left-color: var(--violet); }
|
||||
.echo-entry.echo-2 { border-left-color: var(--amber); }
|
||||
.echo-entry.echo-3 { border-left-color: var(--green); }
|
||||
.echo-glyph { width: 20px; height: 20px; border: 1px solid currentColor; border-radius: 50%; color: var(--coral); box-shadow: 0 0 10px currentColor; }
|
||||
.echo-entry.echo-1 .echo-glyph { color: var(--violet); }
|
||||
.echo-entry.echo-2 .echo-glyph { color: var(--amber); }
|
||||
.echo-entry.echo-3 .echo-glyph { color: var(--green); }
|
||||
.echo-name { display: block; color: var(--ink); font-size: 11px; font-weight: 700; }
|
||||
.echo-meta { display: block; margin-top: 3px; color: var(--dim); font-size: 10px; }
|
||||
.echo-live { color: var(--green); font-size: 9px; font-weight: 800; letter-spacing: .08em; }
|
||||
|
||||
#timeline { display: flex; flex-wrap: wrap; gap: 5px; min-height: 24px; }
|
||||
.timeline-empty { color: var(--dim); font-size: 11px; }
|
||||
.pulse-chip { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid rgba(109, 227, 224, .32); background: rgba(30, 72, 75, .45); color: var(--cyan); font-size: 10px; font-weight: 800; }
|
||||
.pulse-chip.wait { color: var(--dim); border-color: rgba(137,177,190,.18); background: rgba(13,25,31,.55); }
|
||||
.pulse-chip:nth-child(8n+1) { border-color: rgba(255,143,121,.5); color: var(--coral); }
|
||||
.timeline-foot { display: flex; justify-content: space-between; margin-top: 10px; color: var(--dim); font-size: 10px; }
|
||||
|
||||
.level-list { display: grid; gap: 5px; }
|
||||
.level-button { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; min-height: 43px; padding: 7px 10px; border: 1px solid transparent; background: transparent; color: var(--muted); text-align: left; cursor: pointer; }
|
||||
.level-button:hover:not(:disabled) { border-color: var(--line); background: rgba(19, 43, 49, .52); color: var(--ink); }
|
||||
.level-button.active { border-color: rgba(109,227,224,.45); background: rgba(22, 64, 66, .44); color: var(--cyan-bright); }
|
||||
.level-button:disabled { cursor: not-allowed; opacity: .38; }
|
||||
.level-button .level-index { color: inherit; font-size: 10px; }
|
||||
.level-button .level-name { font-size: 11px; font-weight: 700; }
|
||||
.level-button .level-status { color: var(--green); font-size: 15px; }
|
||||
.level-button .level-status.locked { color: var(--dim); font-size: 11px; }
|
||||
|
||||
.footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-top: 17px; border-top: 1px solid var(--line); color: var(--dim); font-size: 10px; letter-spacing: .06em; }
|
||||
.footer a { color: var(--muted); text-decoration: none; }
|
||||
.footer a:hover { color: var(--cyan); }
|
||||
.footer-progress { display: flex; align-items: center; gap: 9px; }
|
||||
.progress-track { width: 100px; height: 3px; background: rgba(137,177,190,.17); }
|
||||
.progress-track span { display: block; width: 0; height: 100%; background: var(--cyan); box-shadow: 0 0 9px var(--cyan); transition: width .35s; }
|
||||
|
||||
dialog { width: min(520px, calc(100% - 30px)); padding: 0; border: 1px solid rgba(109,227,224,.4); border-radius: 6px; background: #091720; color: var(--ink); box-shadow: 0 25px 90px rgba(0,0,0,.7); }
|
||||
dialog::backdrop { background: rgba(1, 6, 10, .77); backdrop-filter: blur(4px); }
|
||||
.dialog-content { padding: 23px; }
|
||||
.dialog-top { display: flex; align-items: start; justify-content: space-between; gap: 15px; padding-bottom: 15px; border-bottom: 1px solid var(--line); }
|
||||
.dialog-top h3 { margin: 0; color: #f4fbfa; font-size: 22px; font-weight: 650; }
|
||||
.dialog-content p { color: var(--muted); font-size: 13px; line-height: 1.75; }
|
||||
.rules { display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.rules li { display: grid; grid-template-columns: 28px 1fr; gap: 8px; color: var(--ink); font-size: 12px; line-height: 1.6; }
|
||||
.rule-no { color: var(--coral); font-weight: 800; }
|
||||
.dialog-close { width: 31px; height: 31px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
|
||||
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 10; max-width: min(360px, calc(100vw - 44px)); padding: 11px 14px; border: 1px solid rgba(109,227,224,.5); border-left: 3px solid var(--cyan); background: #0a1c25; color: var(--cyan-bright); font-size: 12px; line-height: 1.5; opacity: 0; transform: translateY(12px); pointer-events: none; transition: opacity .2s, transform .2s; }
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.success-overlay { position: fixed; inset: 0; z-index: 9; display: grid; place-items: center; padding: 20px; background: rgba(1, 7, 11, .74); opacity: 0; pointer-events: none; transition: opacity .3s; }
|
||||
.success-overlay.show { opacity: 1; pointer-events: auto; }
|
||||
.success-card { width: min(510px, 100%); padding: 30px; border: 1px solid rgba(157,237,167,.56); border-radius: 6px; background: #091a1b; box-shadow: 0 0 70px rgba(78, 186, 144, .17); text-align: center; }
|
||||
.success-card .success-symbol { color: var(--green); font-size: 44px; text-shadow: 0 0 25px var(--green); }
|
||||
.success-card h2 { margin: 11px 0 7px; color: #edfff0; font-size: 28px; font-weight: 650; }
|
||||
.success-card p { margin: 0 0 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||||
.success-card button { min-height: 40px; padding: 0 16px; border: 1px solid rgba(157,237,167,.6); border-radius: 4px; background: rgba(56, 121, 78, .4); color: var(--green); cursor: pointer; font-size: 11px; font-weight: 800; letter-spacing: .07em; }
|
||||
|
||||
@keyframes breathe { 0%,100% { opacity: .5; } 50% { opacity: 1; } }
|
||||
@keyframes goal-pulse { 0%,100% { transform: scale(.85); opacity: .55; } 50% { transform: scale(1.16); opacity: 1; } }
|
||||
@keyframes gate-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes player-pulse { 0%,100% { transform: scale(.84); } 50% { transform: scale(1.08); } }
|
||||
@keyframes echo-pulse { 0%,100% { transform: scale(.78); opacity: .7; } 50% { transform: scale(1); opacity: 1; } }
|
||||
@keyframes flash { 0% { opacity: 0; transform: scale(.96); } 25%,75% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(1.04); } }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.side-column { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
|
||||
.objective-panel { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
:root { --cell: 8.1vw; }
|
||||
.app-shell { padding: 17px 13px 20px; }
|
||||
.topbar { align-items: flex-start; padding-bottom: 20px; }
|
||||
.brand-orbit { width: 38px; height: 38px; font-size: 25px; }
|
||||
.run-state { padding-top: 9px; font-size: 9px; }
|
||||
.run-state .divider, .run-state #level-count { display: none; }
|
||||
.layout { gap: 22px; padding-top: 22px; }
|
||||
.briefing { align-items: flex-start; flex-direction: column; gap: 15px; min-height: 0; padding-bottom: 16px; }
|
||||
.briefing h2 { font-size: 30px; }
|
||||
.briefing-actions { width: 100%; }
|
||||
.quiet-button { flex: 1; }
|
||||
.board-wrap { min-height: 0; padding: 34px 9px; }
|
||||
#board { width: 100%; gap: 2px; }
|
||||
.tile .tile-code { top: 2px; left: 2px; font-size: 6px; }
|
||||
.tile .agent-number { right: 2px; bottom: 2px; font-size: 6px; }
|
||||
.frame-top, .frame-bottom { font-size: 8px; }
|
||||
.control-row { align-items: flex-end; flex-wrap: wrap; }
|
||||
.deck-actions { margin-left: auto; }
|
||||
.deck-actions button { min-height: 36px; padding: 0 10px; font-size: 10px; }
|
||||
.deck-note { flex-basis: 100%; order: 3; text-align: left; }
|
||||
.touch-controls { display: grid; grid-template-columns: repeat(3, 40px); grid-template-rows: repeat(2, 35px); gap: 4px; margin-left: 4px; }
|
||||
.touch-controls .key-button:first-child { grid-column: 2; }
|
||||
.side-column { display: flex; }
|
||||
.footer { align-items: flex-start; flex-direction: column; gap: 9px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="ambient-canvas" aria-hidden="true"></canvas>
|
||||
<div class="noise-layer" aria-hidden="true"></div>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand-mark">
|
||||
<div class="brand-orbit" aria-hidden="true">◌</div>
|
||||
<div>
|
||||
<p>CHRONO LAB / 09</p>
|
||||
<h1>ECHO<span>//</span>LOOP</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="run-state"><span id="state-dot" class="state-dot ready"></span><span id="phase-label">当前时间线</span><span class="divider"></span><span id="level-count">实验 01 / 04</span></div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="play-column" aria-label="游戏区域">
|
||||
<div class="briefing">
|
||||
<div>
|
||||
<p id="level-kicker" class="kicker">实验 01 · 初次接触</p>
|
||||
<h2 id="level-title">把一秒钟留在原地</h2>
|
||||
<p id="level-subtitle" class="subtitle">让你的旧路线成为新世界的开关。</p>
|
||||
</div>
|
||||
<div class="briefing-actions">
|
||||
<button id="reset-level" class="quiet-button" type="button" title="重新开始当前实验">↺ 重置实验</button>
|
||||
<button id="open-help" class="quiet-button" type="button" title="查看游戏规则">? 规则</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-frame">
|
||||
<div class="frame-top"><span>LIVE MEMORY ARRAY</span><div><span class="live">● RECORDING</span><span id="turn-readout">PULSE 00</span></div></div>
|
||||
<div class="board-wrap">
|
||||
<div id="board" aria-label="时间回声棋盘"></div>
|
||||
<div id="flash" class="flash" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="frame-bottom"><span id="pulse-bar">A · 0 个回声在线</span><span>ARROWS / WASD 移动 · SPACE 空拍</span></div>
|
||||
</div>
|
||||
|
||||
<div class="command-deck">
|
||||
<div class="deck-head"><span>COMMAND DECK</span><span id="command-hint">每一步都会留下痕迹</span></div>
|
||||
<div class="control-row">
|
||||
<div class="key-cluster" aria-label="方向控制">
|
||||
<button class="key-button" type="button" data-action="up" aria-label="向上移动" title="向上移动">↑</button>
|
||||
<button class="key-button" type="button" data-action="left" aria-label="向左移动" title="向左移动">←</button>
|
||||
<button class="key-button" type="button" data-action="down" aria-label="向下移动" title="向下移动">↓</button>
|
||||
<button class="key-button" type="button" data-action="right" aria-label="向右移动" title="向右移动">→</button>
|
||||
</div>
|
||||
<div class="touch-controls" aria-label="触屏方向控制">
|
||||
<button class="key-button" type="button" data-action="up" aria-label="向上移动">↑</button>
|
||||
<button class="key-button" type="button" data-action="left" aria-label="向左移动">←</button>
|
||||
<button class="key-button" type="button" data-action="down" aria-label="向下移动">↓</button>
|
||||
<button class="key-button" type="button" data-action="right" aria-label="向右移动">→</button>
|
||||
</div>
|
||||
<div class="deck-actions">
|
||||
<button id="undo-move" class="undo-button" type="button" title="撤销当前时间线最后一步">⌫ 撤销</button>
|
||||
<button id="record-echo" class="record-button" type="button" title="封存当前路线并生成一个回声">◉ 封存回声</button>
|
||||
</div>
|
||||
<p class="deck-note">回声不会互相阻挡。<br>它们只会忠实地重复。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side-column" aria-label="实验信息">
|
||||
<section class="panel objective-panel">
|
||||
<div class="panel-head"><span class="panel-label">MISSION OBJECTIVE</span><span id="par-readout" class="panel-label">PAR 01</span></div>
|
||||
<div class="panel-body">
|
||||
<p id="mission-copy" class="mission-copy">将当前光标送入出口。对应回声站上绿色节点时,闸门会被供能。</p>
|
||||
<div class="objective-row"><span class="objective-icon">◎</span><div><strong>出口坐标</strong><small id="goal-coord">等待扫描</small></div><span id="goal-status" class="objective-status">待抵达</span></div>
|
||||
<div class="objective-row"><span class="objective-icon">⌁</span><div><strong>当前回声</strong><small id="echo-coord">尚未封存任何记忆</small></div><span id="echo-status" class="objective-status">空</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><span class="panel-label">MEMORY BANK</span><span id="bank-count" class="panel-label">00 / 04</span></div>
|
||||
<div class="panel-body"><div id="echo-list"></div></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><span class="panel-label">LIVE TIMELINE</span><span id="timeline-count" class="panel-label">00 PULSES</span></div>
|
||||
<div class="panel-body">
|
||||
<div id="timeline"></div>
|
||||
<div class="timeline-foot"><span>当前路线</span><span id="timeline-score">等待输入</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><span class="panel-label">EXPERIMENTS</span><span class="panel-label">LOCAL</span></div>
|
||||
<div id="level-list" class="panel-body level-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>零依赖 · 状态只保存在本机浏览器</span>
|
||||
<div class="footer-progress"><span id="footer-progress-label">0 / 4 已完成</span><span class="progress-track"><span id="progress-fill"></span></span></div>
|
||||
<a href="https://git.siriusliang.site/kimsongwu/echo-loop" target="_blank" rel="noreferrer">SOURCE / GITEA ↗</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog id="help-dialog">
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-top"><h3>时间回声协议</h3><button id="close-help" class="dialog-close" type="button" aria-label="关闭规则">×</button></div>
|
||||
<p>你无法同时站在两个地方,但你可以把过去留在那里。每次封存都会把当前路线变成一个从起点重播的回声。</p>
|
||||
<ol class="rules">
|
||||
<li><span class="rule-no">01</span><span>用方向键、WASD 或棋盘下方的方向键移动。每一次按键都是一个脉冲,撞墙也会消耗时间。</span></li>
|
||||
<li><span class="rule-no">02</span><span>站到绿色节点上,然后点击“封存回声”。下一条时间线开始时,回声会重走这条路线。</span></li>
|
||||
<li><span class="rule-no">03</span><span>回声站在对应节点上时,同色闸门会打开。老回声会一直重复自己的路线,并不会互相挡路。</span></li>
|
||||
<li><span class="rule-no">04</span><span>抵达金色出口即可完成实验。Backspace 撤销当前路线最后一步,R 重置实验,Space 原地等待一拍。</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<div id="success-overlay" class="success-overlay" aria-hidden="true">
|
||||
<div class="success-card">
|
||||
<div class="success-symbol">◌</div>
|
||||
<h2 id="success-title">回声已同步</h2>
|
||||
<p id="success-copy">出口回应了你的时间线。</p>
|
||||
<button id="success-next" type="button">进入下一实验 →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'echo-loop-progress-v1';
|
||||
const MAX_PULSES = 120;
|
||||
const DIRS = Object.freeze({
|
||||
up: { dx: 0, dy: -1, key: 'U', label: '上' },
|
||||
left: { dx: -1, dy: 0, key: 'L', label: '左' },
|
||||
down: { dx: 0, dy: 1, key: 'D', label: '下' },
|
||||
right: { dx: 1, dy: 0, key: 'R', label: '右' },
|
||||
wait: { dx: 0, dy: 0, key: '·', label: '空拍' }
|
||||
});
|
||||
|
||||
const LEVELS = Object.freeze([
|
||||
{
|
||||
id: 'first-contact',
|
||||
name: '初次接触',
|
||||
title: '把一秒钟留在原地',
|
||||
subtitle: '让你的旧路线成为新世界的开关。',
|
||||
hint: '先把光标送到绿色节点,再封存路线。',
|
||||
par: 2,
|
||||
map: [
|
||||
'###########',
|
||||
'#S..aA...G#',
|
||||
'###########'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'split-signal',
|
||||
name: '分裂信号',
|
||||
title: '让两段过去同时成立',
|
||||
subtitle: '第一道门记得你,第二道门还没有。',
|
||||
hint: '需要两个回声。第二个节点在第一道闸门之后。',
|
||||
par: 3,
|
||||
map: [
|
||||
'##############',
|
||||
'#S..a...A.bBG#',
|
||||
'##############'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'borrowed-time',
|
||||
name: '借来的时间',
|
||||
title: '穿过一条不相信你的路',
|
||||
subtitle: '让回声走捷径,你走远路。',
|
||||
hint: '左侧下行路线可以抵达第二节点;出口在下方。',
|
||||
par: 3,
|
||||
map: [
|
||||
'###############',
|
||||
'#S..aA........#',
|
||||
'#.###.#####.#.#',
|
||||
'#...#.....#...#',
|
||||
'###.#.###.#.#.#',
|
||||
'#b..#...B...G.#',
|
||||
'#.###########.#',
|
||||
'#.............#',
|
||||
'###############'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'last-room',
|
||||
name: '最后一室',
|
||||
title: '把自己交给未来',
|
||||
subtitle: '三枚节点,三段记忆,一个出口。',
|
||||
hint: '按顺序封存 a、b、c。直线有时是最难的迷宫。',
|
||||
par: 4,
|
||||
map: [
|
||||
'#####################',
|
||||
'#S..a..A..b..B..c.CG#',
|
||||
'#####################'
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
const state = {
|
||||
levelIndex: 0,
|
||||
player: { x: 0, y: 0 },
|
||||
start: { x: 0, y: 0 },
|
||||
goal: { x: 0, y: 0 },
|
||||
plates: {},
|
||||
gates: {},
|
||||
echoes: [],
|
||||
actions: [],
|
||||
pulse: 0,
|
||||
completed: [],
|
||||
levelComplete: false,
|
||||
noticeTimer: null
|
||||
};
|
||||
|
||||
const els = {};
|
||||
|
||||
function byId(id) { return document.getElementById(id); }
|
||||
|
||||
function loadProgress() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
if (Array.isArray(saved.completed)) {
|
||||
state.completed = saved.completed.filter((id) => LEVELS.some((level) => level.id === id));
|
||||
}
|
||||
if (Number.isInteger(saved.levelIndex) && saved.levelIndex >= 0 && saved.levelIndex < LEVELS.length) {
|
||||
state.levelIndex = saved.levelIndex;
|
||||
}
|
||||
} catch (error) {
|
||||
state.completed = [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ completed: state.completed, levelIndex: state.levelIndex }));
|
||||
} catch (error) {
|
||||
// Private browsing may disable storage. The game remains fully playable.
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevel(level) {
|
||||
const width = level.map[0].length;
|
||||
const height = level.map.length;
|
||||
const plates = {};
|
||||
const gates = {};
|
||||
let start = null;
|
||||
let goal = null;
|
||||
level.map.forEach((row, y) => {
|
||||
if (row.length !== width) throw new Error('地图宽度不一致');
|
||||
[...row].forEach((symbol, x) => {
|
||||
if (symbol === 'S') start = { x, y };
|
||||
if (symbol === 'G') goal = { x, y };
|
||||
if (/[a-z]/.test(symbol)) plates[symbol] = { x, y };
|
||||
if (/[A-Z]/.test(symbol) && symbol !== 'S' && symbol !== 'G') gates[symbol.toLowerCase()] = { x, y, code: symbol.toLowerCase() };
|
||||
});
|
||||
});
|
||||
if (!start || !goal) throw new Error('地图缺少起点或出口');
|
||||
return { width, height, start, goal, plates, gates };
|
||||
}
|
||||
|
||||
function loadLevel(index, announce) {
|
||||
const safeIndex = Math.max(0, Math.min(index, LEVELS.length - 1));
|
||||
const level = LEVELS[safeIndex];
|
||||
const parsed = parseLevel(level);
|
||||
state.levelIndex = safeIndex;
|
||||
state.player = { ...parsed.start };
|
||||
state.start = { ...parsed.start };
|
||||
state.goal = { ...parsed.goal };
|
||||
state.plates = parsed.plates;
|
||||
state.gates = parsed.gates;
|
||||
state.echoes = [];
|
||||
state.actions = [];
|
||||
state.pulse = 0;
|
||||
state.levelComplete = false;
|
||||
hideSuccess();
|
||||
render();
|
||||
if (announce) showToast(`实验 ${String(safeIndex + 1).padStart(2, '0')} 已载入`);
|
||||
}
|
||||
|
||||
function level() { return LEVELS[state.levelIndex]; }
|
||||
|
||||
function tileAt(x, y) {
|
||||
const currentLevel = level();
|
||||
if (y < 0 || y >= currentLevel.map.length || x < 0 || x >= currentLevel.map[0].length) return '#';
|
||||
return currentLevel.map[y][x];
|
||||
}
|
||||
|
||||
function isGate(symbol) { return /^[A-Z]$/.test(symbol) && symbol !== 'S' && symbol !== 'G'; }
|
||||
function isPlate(symbol) { return /^[a-z]$/.test(symbol); }
|
||||
|
||||
function activePlates() {
|
||||
const active = new Set();
|
||||
Object.entries(state.plates).forEach(([code, point]) => {
|
||||
const occupiedByEcho = state.echoes.some((echo) => echo.x === point.x && echo.y === point.y);
|
||||
const occupiedByPlayer = state.player.x === point.x && state.player.y === point.y;
|
||||
if (occupiedByEcho || occupiedByPlayer) active.add(code);
|
||||
});
|
||||
return active;
|
||||
}
|
||||
|
||||
function isWalkable(x, y) {
|
||||
const symbol = tileAt(x, y);
|
||||
if (symbol === '#') return false;
|
||||
if (isGate(symbol)) return activePlates().has(symbol.toLowerCase());
|
||||
return true;
|
||||
}
|
||||
|
||||
function moveEntity(entity, action) {
|
||||
const direction = DIRS[action] || DIRS.wait;
|
||||
const nextX = entity.x + direction.dx;
|
||||
const nextY = entity.y + direction.dy;
|
||||
if (direction.dx === 0 && direction.dy === 0) return false;
|
||||
if (!isWalkable(nextX, nextY)) return false;
|
||||
entity.x = nextX;
|
||||
entity.y = nextY;
|
||||
return true;
|
||||
}
|
||||
|
||||
function advanceEchoes(pulse) {
|
||||
state.echoes.forEach((echo) => {
|
||||
const action = echo.actions[pulse];
|
||||
if (action) moveEntity(echo, action);
|
||||
});
|
||||
}
|
||||
|
||||
function resetEchoPositions() {
|
||||
state.echoes.forEach((echo) => {
|
||||
echo.x = state.start.x;
|
||||
echo.y = state.start.y;
|
||||
});
|
||||
}
|
||||
|
||||
function replayCurrentActions() {
|
||||
state.player = { ...state.start };
|
||||
resetEchoPositions();
|
||||
state.pulse = 0;
|
||||
for (let pulse = 0; pulse < state.actions.length; pulse += 1) {
|
||||
advanceEchoes(pulse);
|
||||
moveEntity(state.player, state.actions[pulse]);
|
||||
state.pulse += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function performAction(action) {
|
||||
if (state.levelComplete || state.actions.length >= MAX_PULSES) return;
|
||||
advanceEchoes(state.pulse);
|
||||
moveEntity(state.player, action);
|
||||
state.actions.push(action);
|
||||
state.pulse += 1;
|
||||
if (state.player.x === state.goal.x && state.player.y === state.goal.y) {
|
||||
completeLevel();
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function undoAction() {
|
||||
if (state.levelComplete || state.actions.length === 0) return;
|
||||
state.actions.pop();
|
||||
replayCurrentActions();
|
||||
render();
|
||||
showToast('最后一个脉冲已撤销');
|
||||
}
|
||||
|
||||
function recordEcho() {
|
||||
if (state.levelComplete) return;
|
||||
if (state.actions.length === 0) {
|
||||
showToast('还没有可封存的路线');
|
||||
return;
|
||||
}
|
||||
const echoNumber = state.echoes.length + 1;
|
||||
state.echoes.push({ id: echoNumber, x: state.start.x, y: state.start.y, actions: [...state.actions] });
|
||||
resetEchoPositions();
|
||||
state.actions = [];
|
||||
state.player = { ...state.start };
|
||||
state.pulse = 0;
|
||||
render();
|
||||
showFlash(`ECHO ${String(echoNumber).padStart(2, '0')} ONLINE`);
|
||||
showToast(`回声 ${echoNumber} 已上线 · 下一条时间线开始`);
|
||||
}
|
||||
|
||||
function resetLevel(announce) {
|
||||
loadLevel(state.levelIndex, false);
|
||||
if (announce) showToast('当前实验已重置');
|
||||
}
|
||||
|
||||
function completeLevel() {
|
||||
state.levelComplete = true;
|
||||
const currentId = level().id;
|
||||
if (!state.completed.includes(currentId)) state.completed.push(currentId);
|
||||
saveProgress();
|
||||
render();
|
||||
showFlash('TEMPORAL LOCK OPEN');
|
||||
const isFinal = state.levelIndex === LEVELS.length - 1;
|
||||
els.successTitle.textContent = isFinal ? '实验全部完成' : '回声已同步';
|
||||
els.successCopy.textContent = isFinal ? '你已经证明:过去不是负担,它是一支队伍。' : `${level().name} 已通过。你用了 ${state.echoes.length} 个回声抵达出口。`;
|
||||
els.successNext.textContent = isFinal ? '重新观测第一实验 ↺' : '进入下一实验 →';
|
||||
els.successOverlay.classList.add('show');
|
||||
els.successOverlay.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
|
||||
function nextLevel() {
|
||||
hideSuccess();
|
||||
if (state.levelIndex === LEVELS.length - 1) loadLevel(0, true);
|
||||
else loadLevel(state.levelIndex + 1, true);
|
||||
}
|
||||
|
||||
function hideSuccess() {
|
||||
els.successOverlay.classList.remove('show');
|
||||
els.successOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
clearTimeout(state.noticeTimer);
|
||||
els.toast.textContent = message;
|
||||
els.toast.classList.add('show');
|
||||
state.noticeTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
|
||||
}
|
||||
|
||||
function showFlash(message) {
|
||||
els.flash.textContent = message;
|
||||
els.flash.classList.remove('show');
|
||||
void els.flash.offsetWidth;
|
||||
els.flash.classList.add('show');
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
const currentLevel = level();
|
||||
const active = activePlates();
|
||||
const entities = new Map();
|
||||
state.echoes.forEach((echo, index) => {
|
||||
const key = `${echo.x},${echo.y}`;
|
||||
if (!entities.has(key)) entities.set(key, []);
|
||||
entities.get(key).push({ type: 'echo', index });
|
||||
});
|
||||
const playerKey = `${state.player.x},${state.player.y}`;
|
||||
if (!entities.has(playerKey)) entities.set(playerKey, []);
|
||||
entities.get(playerKey).push({ type: 'current' });
|
||||
|
||||
els.board.style.setProperty('--cols', currentLevel.map[0].length);
|
||||
els.board.style.setProperty('--rows', currentLevel.map.length);
|
||||
els.board.innerHTML = '';
|
||||
currentLevel.map.forEach((row, y) => [...row].forEach((symbol, x) => {
|
||||
const cell = document.createElement('div');
|
||||
const classes = ['tile'];
|
||||
if (symbol === '#') classes.push('wall');
|
||||
else classes.push('floor');
|
||||
if (symbol === 'S') classes.push('start');
|
||||
if (symbol === 'G') classes.push('goal');
|
||||
if (isPlate(symbol)) classes.push('plate');
|
||||
if (isGate(symbol)) {
|
||||
classes.push('gate');
|
||||
if (active.has(symbol.toLowerCase())) classes.push('open');
|
||||
}
|
||||
const occupants = entities.get(`${x},${y}`) || [];
|
||||
occupants.forEach((occupant) => classes.push(occupant.type === 'current' ? 'current' : `echo echo-${(occupant.index % 3) + 1}`));
|
||||
cell.className = classes.join(' ');
|
||||
cell.setAttribute('role', 'gridcell');
|
||||
cell.setAttribute('aria-label', describeTile(symbol, x, y, occupants, active));
|
||||
if (isPlate(symbol) || isGate(symbol)) {
|
||||
const code = document.createElement('span');
|
||||
code.className = 'tile-code';
|
||||
code.textContent = symbol.toUpperCase();
|
||||
cell.appendChild(code);
|
||||
}
|
||||
occupants.forEach((occupant) => {
|
||||
if (occupant.type === 'echo') {
|
||||
const number = document.createElement('span');
|
||||
number.className = 'agent-number';
|
||||
number.textContent = occupant.index + 1;
|
||||
cell.appendChild(number);
|
||||
}
|
||||
});
|
||||
els.board.appendChild(cell);
|
||||
}));
|
||||
}
|
||||
|
||||
function describeTile(symbol, x, y, occupants, active) {
|
||||
let name = symbol === '#' ? '墙' : symbol === 'G' ? '出口' : symbol === 'S' ? '起点' : isPlate(symbol) ? `节点 ${symbol.toUpperCase()}` : isGate(symbol) ? `闸门 ${symbol} ${active.has(symbol.toLowerCase()) ? '已开启' : '关闭'}` : '通道';
|
||||
if (occupants.some((item) => item.type === 'current')) name += ',当前光标';
|
||||
if (occupants.some((item) => item.type === 'echo')) name += ',回声';
|
||||
return `${name},坐标 ${x + 1},${y + 1}`;
|
||||
}
|
||||
|
||||
function renderEchoes() {
|
||||
els.echoList.innerHTML = '';
|
||||
if (state.echoes.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-bank';
|
||||
empty.innerHTML = '<span class="empty-orbit">◌</span><span>记忆槽为空。<br>你的第一条路线还在现在。</span>';
|
||||
els.echoList.appendChild(empty);
|
||||
} else {
|
||||
state.echoes.forEach((echo, index) => {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `echo-entry echo-${(index % 3) + 1}`;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'echo-glyph';
|
||||
const copy = document.createElement('div');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'echo-name';
|
||||
name.textContent = `ECHO ${String(index + 1).padStart(2, '0')}`;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'echo-meta';
|
||||
meta.textContent = `${echo.actions.length} 个脉冲 · ${echo.x + 1},${echo.y + 1}`;
|
||||
copy.append(name, meta);
|
||||
const live = document.createElement('span');
|
||||
live.className = 'echo-live';
|
||||
live.textContent = state.pulse < echo.actions.length ? 'REPLAY' : 'HOLD';
|
||||
entry.append(icon, copy, live);
|
||||
els.echoList.appendChild(entry);
|
||||
});
|
||||
}
|
||||
els.bankCount.textContent = `${String(state.echoes.length).padStart(2, '0')} / 04`;
|
||||
els.echoCoord.textContent = state.echoes.length ? `${state.echoes.length} 条记忆正在从起点重播` : '尚未封存任何记忆';
|
||||
els.echoStatus.textContent = state.echoes.length ? '在线' : '空';
|
||||
els.echoStatus.className = `objective-status${state.echoes.length ? ' live' : ''}`;
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
els.timeline.innerHTML = '';
|
||||
if (state.actions.length === 0) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'timeline-empty';
|
||||
empty.textContent = '等待当前时间线输入…';
|
||||
els.timeline.appendChild(empty);
|
||||
} else {
|
||||
state.actions.forEach((action, index) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `pulse-chip${action === 'wait' ? ' wait' : ''}`;
|
||||
chip.title = `脉冲 ${index + 1}: ${DIRS[action].label}`;
|
||||
chip.textContent = DIRS[action].key;
|
||||
els.timeline.appendChild(chip);
|
||||
});
|
||||
}
|
||||
els.timelineCount.textContent = `${String(state.actions.length).padStart(2, '0')} PULSES`;
|
||||
els.timelineScore.textContent = state.actions.length ? `${state.actions.length <= level().par ? 'ON PAR' : 'EXPLORING'}` : '等待输入';
|
||||
}
|
||||
|
||||
function renderLevels() {
|
||||
els.levelList.innerHTML = '';
|
||||
LEVELS.forEach((item, index) => {
|
||||
const unlocked = index === 0 || state.completed.includes(LEVELS[index - 1].id) || state.completed.includes(item.id);
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `level-button${index === state.levelIndex ? ' active' : ''}`;
|
||||
button.disabled = !unlocked;
|
||||
button.dataset.level = index;
|
||||
button.innerHTML = `<span class="level-index">0${index + 1}</span><span class="level-name">${item.name}</span><span class="level-status${unlocked ? '' : ' locked'}">${state.completed.includes(item.id) ? '✓' : unlocked ? '○' : '×'}</span>`;
|
||||
button.addEventListener('click', () => loadLevel(index, true));
|
||||
els.levelList.appendChild(button);
|
||||
});
|
||||
const completedCount = state.completed.length;
|
||||
els.footerProgressLabel.textContent = `${completedCount} / ${LEVELS.length} 已完成`;
|
||||
els.progressFill.style.width = `${(completedCount / LEVELS.length) * 100}%`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const currentLevel = level();
|
||||
const active = activePlates();
|
||||
els.levelKicker.textContent = `实验 ${String(state.levelIndex + 1).padStart(2, '0')} · ${currentLevel.name}`;
|
||||
els.levelTitle.textContent = currentLevel.title;
|
||||
els.levelSubtitle.textContent = currentLevel.subtitle;
|
||||
els.levelCount.textContent = `实验 ${String(state.levelIndex + 1).padStart(2, '0')} / ${String(LEVELS.length).padStart(2, '0')}`;
|
||||
els.parReadout.textContent = `PAR ${String(currentLevel.par).padStart(2, '0')}`;
|
||||
els.missionCopy.textContent = currentLevel.hint;
|
||||
els.goalCoord.textContent = `坐标 ${state.goal.x + 1},${state.goal.y + 1} · 金色出口`;
|
||||
els.goalStatus.textContent = state.levelComplete ? '已抵达' : '待抵达';
|
||||
els.goalStatus.className = `objective-status${state.levelComplete ? ' done' : ''}`;
|
||||
els.turnReadout.textContent = `PULSE ${String(state.pulse).padStart(2, '0')}`;
|
||||
els.pulseBar.textContent = `${Object.keys(state.gates).map((code) => `${code.toUpperCase()} · ${active.has(code) ? 'OPEN' : 'LOCK'}`).join(' ') || 'NO GATES'} · ${state.echoes.length} 个回声在线`;
|
||||
els.commandHint.textContent = state.actions.length ? '路线正在被记录' : currentLevel.hint;
|
||||
els.stateDot.className = `state-dot${state.echoes.length ? '' : ' ready'}`;
|
||||
els.phaseLabel.textContent = state.levelComplete ? '实验已完成' : state.echoes.length ? '回声同步中' : '当前时间线';
|
||||
renderBoard();
|
||||
renderEchoes();
|
||||
renderTimeline();
|
||||
renderLevels();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
const tag = event.target && event.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
const keyMap = { ArrowUp: 'up', w: 'up', W: 'up', ArrowLeft: 'left', a: 'left', A: 'left', ArrowDown: 'down', s: 'down', S: 'down', ArrowRight: 'right', d: 'right', D: 'right' };
|
||||
if (keyMap[event.key]) {
|
||||
event.preventDefault();
|
||||
pressButton(keyMap[event.key]);
|
||||
performAction(keyMap[event.key]);
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
performAction('wait');
|
||||
} else if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
undoAction();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
recordEcho();
|
||||
} else if (event.key === 'r' || event.key === 'R') {
|
||||
resetLevel(true);
|
||||
}
|
||||
}
|
||||
|
||||
function pressButton(action) {
|
||||
document.querySelectorAll(`[data-action="${action}"]`).forEach((button) => {
|
||||
button.classList.add('pressed');
|
||||
setTimeout(() => button.classList.remove('pressed'), 130);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.querySelectorAll('[data-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => performAction(button.dataset.action));
|
||||
});
|
||||
els.recordEcho.addEventListener('click', recordEcho);
|
||||
els.undoMove.addEventListener('click', undoAction);
|
||||
els.resetLevel.addEventListener('click', () => resetLevel(true));
|
||||
els.openHelp.addEventListener('click', () => els.helpDialog.showModal());
|
||||
els.closeHelp.addEventListener('click', () => els.helpDialog.close());
|
||||
els.helpDialog.addEventListener('click', (event) => { if (event.target === els.helpDialog) els.helpDialog.close(); });
|
||||
els.successNext.addEventListener('click', nextLevel);
|
||||
els.successOverlay.addEventListener('click', (event) => { if (event.target === els.successOverlay) hideSuccess(); });
|
||||
}
|
||||
|
||||
function initAmbient() {
|
||||
const canvas = byId('ambient-canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
const motes = Array.from({ length: 50 }, (_, index) => ({
|
||||
x: (index * 83) % 1000,
|
||||
y: (index * 137) % 700,
|
||||
radius: 0.5 + (index % 4) * .35,
|
||||
speed: .05 + (index % 5) * .015,
|
||||
phase: index * 1.7,
|
||||
color: index % 3 === 0 ? '#ff8f79' : index % 3 === 1 ? '#6de3e0' : '#c0a7ff'
|
||||
}));
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
function draw(time) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
motes.forEach((mote) => {
|
||||
const x = (mote.x + time * mote.speed * .01) % (canvas.width + 60) - 30;
|
||||
const y = (mote.y + Math.sin(time * .00035 + mote.phase) * 24) % (canvas.height + 40);
|
||||
const alpha = .18 + (Math.sin(time * .001 + mote.phase) + 1) * .1;
|
||||
context.globalAlpha = alpha;
|
||||
context.fillStyle = mote.color;
|
||||
context.fillRect(x, y, mote.radius, mote.radius);
|
||||
});
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
resize();
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
function exposeTestApi() {
|
||||
window.__ECHO_LOOP__ = Object.freeze({
|
||||
state,
|
||||
levels: LEVELS,
|
||||
loadLevel,
|
||||
performAction,
|
||||
recordEcho,
|
||||
resetLevel,
|
||||
activePlates,
|
||||
isWalkable
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
Object.assign(els, {
|
||||
board: byId('board'),
|
||||
flash: byId('flash'),
|
||||
toast: byId('toast'),
|
||||
levelKicker: byId('level-kicker'),
|
||||
levelTitle: byId('level-title'),
|
||||
levelSubtitle: byId('level-subtitle'),
|
||||
levelCount: byId('level-count'),
|
||||
parReadout: byId('par-readout'),
|
||||
missionCopy: byId('mission-copy'),
|
||||
goalCoord: byId('goal-coord'),
|
||||
goalStatus: byId('goal-status'),
|
||||
echoCoord: byId('echo-coord'),
|
||||
echoStatus: byId('echo-status'),
|
||||
echoList: byId('echo-list'),
|
||||
bankCount: byId('bank-count'),
|
||||
timeline: byId('timeline'),
|
||||
timelineCount: byId('timeline-count'),
|
||||
timelineScore: byId('timeline-score'),
|
||||
levelList: byId('level-list'),
|
||||
footerProgressLabel: byId('footer-progress-label'),
|
||||
progressFill: byId('progress-fill'),
|
||||
pulseBar: byId('pulse-bar'),
|
||||
commandHint: byId('command-hint'),
|
||||
turnReadout: byId('turn-readout'),
|
||||
phaseLabel: byId('phase-label'),
|
||||
stateDot: byId('state-dot'),
|
||||
recordEcho: byId('record-echo'),
|
||||
undoMove: byId('undo-move'),
|
||||
resetLevel: byId('reset-level'),
|
||||
openHelp: byId('open-help'),
|
||||
helpDialog: byId('help-dialog'),
|
||||
closeHelp: byId('close-help'),
|
||||
successOverlay: byId('success-overlay'),
|
||||
successTitle: byId('success-title'),
|
||||
successCopy: byId('success-copy'),
|
||||
successNext: byId('success-next')
|
||||
});
|
||||
loadProgress();
|
||||
initAmbient();
|
||||
bindEvents();
|
||||
loadLevel(state.levelIndex, false);
|
||||
exposeTestApi();
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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, /<!doctype 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(/<script(?:\s[^>]*)?>([\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');
|
||||
Reference in New Issue
Block a user