Files
echo-loop/index.html
T

967 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 MAX_ECHOES = 4;
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);
if (occupiedByEcho) 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;
}
if (state.echoes.length >= MAX_ECHOES) {
showToast('记忆库已满 · 先用现有回声完成实验');
return;
}
const currentPlate = tileAt(state.player.x, state.player.y);
if (!isPlate(currentPlate)) {
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')} / ${String(MAX_ECHOES).padStart(2, '0')}`;
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>