aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/live-rules/web/app.js
blob: d8ca81502ef5bea64fd53bc6ed09aedb1b9fc492 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
"use strict";

(() => {
  const $ = (id) => document.getElementById(id);
  const field = Object.freeze({ tick: 0, y: 1, vy: 2, distance: 3, obstacleX: 4, passed: 5, hits: 6, jumps: 7, running: 8, revision: 9, reason: 10, source: 11, inputs: 12, accepted: 13, mode: 14, impulse: 15, enabled: 16, grounded: 17, obstacleHeight: 18 });
  const reasonText = ["아직 입력 없음", "점프를 받아들였어요.", "점프 입력이 꺼져 있어요.", "땅에 닿아야 다시 뛸 수 있어요.", "공중에서 쓸 점프를 모두 썼어요.", "알 수 없는 입력이에요."];
  const sourceText = ["키보드", "점프 버튼", "CLI"];
  const frameMs = 1000 / 60;
  let core = null;
  let ready = false;
  let applied = null;
  let lastTime = null;
  let accumulator = 0;
  let droppedTicks = 0;
  let renderCount = 0;
  let historySignature = "";
  let notice = "";
  const nodes = [...document.querySelectorAll("[data-node]")];

  function text(id, value) {
    const element = $(id);
    const next = String(value);
    if (element.textContent !== next) element.textContent = next;
  }

  function get(name) { return core.get(field[name]); }
  function counter(name) { return get(name) >>> 0; }
  function say(message) { text("live-status", message); }
  function formatBytes(value) { return `${(value >>> 0).toLocaleString("ko-KR")} B`; }
  function draft() {
    return { mode: Number(document.querySelector('input[name="mode"]:checked').value), impulse: Number($("impulse").value), enabled: $("enabled").checked ? 1 : 0 };
  }
  function isDirty() {
    if (!applied) return false;
    const next = draft();
    return next.mode !== applied.mode || next.impulse !== applied.impulse || next.enabled !== applied.enabled;
  }
  function draftChanged() {
    text("impulse-output", $("impulse").value);
    const dirty = isDirty();
    text("draft-status", dirty ? "아직 적용하지 않은 변경" : "현재 규칙과 같음");
    $("draft-status").parentElement.classList.toggle("dirty", dirty);
    $("apply-button").disabled = !ready || !dirty;
    if (dirty) text("apply-feedback", "아직 이전 규칙으로 움직입니다. 적용하면 위치와 시간은 그대로 이어집니다.");
    else if (ready) text("apply-feedback", "바꾼 뒤 적용을 눌러주세요. 위치와 진행 시간은 이어집니다.");
  }
  function syncApplied() {
    applied = { mode: get("mode"), impulse: get("impulse"), enabled: get("enabled") };
    document.querySelector(`input[name="mode"][value="${applied.mode}"]`).checked = true;
    $("impulse").value = String(applied.impulse);
    $("enabled").checked = Boolean(applied.enabled);
    draftChanged();
  }

  function renderHistory() {
    const entries = [];
    for (let i = 0, count = Math.min(8, core.history_count()); i < count; i++) {
      entries.push([0, 1, 2, 3].map((part) => core.history_get(i, part) >>> 0));
    }
    const signature = JSON.stringify(entries);
    if (signature === historySignature) return;
    historySignature = signature;
    const list = $("history-list");
    list.replaceChildren();
    if (entries.length === 0) {
      const empty = document.createElement("li");
      empty.className = "empty-history";
      empty.textContent = "점프하거나 규칙을 적용하면 이곳에 기록됩니다.";
      list.append(empty);
      return;
    }
    for (const [tick, kind, detail, revision] of entries) {
      const row = document.createElement("li");
      const time = document.createElement("span");
      time.className = "history-time";
      time.textContent = `${tick}t`;
      const label = document.createElement("span");
      label.className = "history-label";
      const descriptions = { 1: "점프 허용", 2: reasonText[detail] || "점프 거절", 3: "규칙 적용", 4: "땅에 착지", 5: "장애물에 닿음", 6: "장애물을 지남" };
      label.textContent = `${descriptions[kind] || "상태 변경"} · v${revision}`;
      row.append(time, label);
      list.append(row);
    }
  }

  function renderDecision() {
    const reason = get("reason");
    const result = $("decision-result");
    result.classList.toggle("accepted", reason === 1);
    result.classList.toggle("rejected", reason > 1);
    text("decision-result", reason === 0 ? "첫 점프를 기다리고 있어요." : reasonText[reason] || "입력을 확인해주세요.");
    if (reason === 0) {
      text("decision-explanation", "한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.");
      text("decision-meta", "아직 입력 없음");
      $("stale-decision").hidden = true;
    } else {
      const preY = core.decision_meta(2);
      const preJumps = core.decision_meta(3);
      const revision = core.decision_meta(1) >>> 0;
      text("decision-explanation", `누른 순간: 높이 ${(preY / 100).toFixed(1)} px · 사용한 점프 ${preJumps}회. ${get("running") ? "이후에도 세계는 움직입니다." : "멈춘 상태에서 판단을 살펴보고 있어요."}`);
      text("decision-meta", `${sourceText[get("source")] || "알 수 없는 입력"} · ${core.decision_meta(0) >>> 0} tick · v${revision}`);
      $("stale-decision").hidden = revision === counter("revision");
    }
    for (const node of nodes) {
      const value = core.node_value(Number(node.dataset.node));
      if (value < 0) delete node.dataset.result;
      else node.dataset.result = String(Boolean(value));
      const output = node.querySelector(".node-value");
      const next = value < 0 ? "대기" : value ? "예" : "아니요";
      if (output.textContent !== next) output.textContent = next;
    }
  }

  function render() {
    if (!ready) return;
    renderCount++;
    const y = get("y") / 100;
    const velocity = get("vy") / 100;
    const tick = counter("tick");
    const running = Boolean(get("running"));
    const obstacleHeight = get("obstacleHeight");
    const cameraY = Math.min(0, 180 - y);
    const viewWidth = $("stage").clientWidth < 500 ? 500 : 960;
    $("world").setAttribute("viewBox", `0 ${cameraY} ${viewWidth} 350`);
    $("dino").setAttribute("transform", `translate(120 ${230 - y})`);
    $("dino-shadow").setAttribute("rx", String(Math.max(10, 27 - y / 12)));
    $("dino-shadow").setAttribute("opacity", String(Math.max(0.05, 0.15 - y / 1600)));
    $("obstacle").setAttribute("transform", `translate(${get("obstacleX")} ${278 - obstacleHeight})`);
    $("obstacle-body").setAttribute("height", String(obstacleHeight));
    $("obstacle-mark").setAttribute("d", `M11 10v${Math.max(0, obstacleHeight - 20)}m10-${Math.max(0, obstacleHeight - 27)}v${Math.max(0, obstacleHeight - 27)}`);
    text("running-badge", running ? "실행 중" : "멈춰 있음");
    $("running-badge").classList.toggle("running", running);
    text("run-button", running ? "일시정지" : "실행");
    $("run-button").setAttribute("aria-pressed", String(running));
    $("step-button").disabled = running;
    text("stage-hint", cameraY < 0 ? "높이에 맞춰 시점이 공룡을 따라갑니다." : running ? "Space 또는 점프 버튼 · 멈춰서 규칙을 바꿔도 좋아요." : "일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.");
    text("state-height", `${y.toFixed(1)} px`);
    text("state-velocity", `${velocity > 0 ? "+" : ""}${velocity.toFixed(1)} px/틱`);
    text("state-ground", get("grounded") ? "땅 위" : "공중");
    text("state-jumps", `${counter("jumps")}회`);
    text("state-distance", `${counter("distance").toLocaleString("ko-KR")} px`);
    text("state-time", `${(tick / 60).toFixed(2)}초`);
    text("state-tick", tick);
    text("state-passed", counter("passed"));
    text("state-hits", counter("hits"));
    text("revision-badge", `v${counter("revision")}`);
    text("applied-label", `적용 v${counter("revision")}`);
    text("runtime-note", notice || (droppedTicks > 0 ? `화면 지연으로 ${droppedTicks.toLocaleString("ko-KR")}틱을 생략했습니다. 진행 시간은 실제 시간보다 느릴 수 있어요.` : "실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요."));
    text("scheduling-detail", `고정 60Hz · 한 화면 갱신당 최대 6틱 · 생략한 틱 ${droppedTicks.toLocaleString("ko-KR")}`);
    text("bytes-linear", core.memory ? formatBytes(core.memory.buffer.byteLength) : "메모리 내보내기 없음");
    renderDecision();
    renderHistory();
  }

  function jump(source) {
    if (!ready) return;
    core.input(source);
    render();
    say(reasonText[get("reason")] || "입력 처리 완료");
  }

  function setRunning(next) {
    core.set_running(next ? 1 : 0);
    accumulator = 0;
    lastTime = null;
    notice = "";
    render();
    say(next ? "실행했습니다." : "멈췄습니다. 규칙을 바꾸거나 한 프레임씩 살펴볼 수 있어요.");
  }

  function frame(now) {
    if (ready) {
      if (get("running") && !document.hidden) {
        if (lastTime !== null) {
          accumulator += Math.max(0, now - lastTime);
          const due = Math.floor(accumulator / frameMs);
          if (due > 0) {
            const ticks = Math.min(6, due);
            droppedTicks += due - ticks;
            accumulator -= due * frameMs;
            core.advance(ticks);
            render();
          }
        }
        lastTime = now;
      } else {
        lastTime = null;
        accumulator = 0;
      }
    }
    window.requestAnimationFrame(frame);
  }

  $("rules-form").addEventListener("input", draftChanged);
  $("rules-form").addEventListener("change", draftChanged);
  $("rules-form").addEventListener("submit", (event) => {
    event.preventDefault();
    if (!ready || !isDirty()) return;
    const next = draft();
    if (core.apply_rules(next.mode, next.impulse, next.enabled) !== 0) {
      text("apply-feedback", "적용하지 못했습니다. 점프 세기는 6~16이어야 합니다.");
      say("규칙을 적용하지 못했습니다.");
      return;
    }
    applied = next;
    draftChanged();
    const feedback = `v${counter("revision")} 적용 완료. 위치·속도·진행 시간은 그대로 이어집니다.`;
    text("apply-feedback", feedback);
    render();
    say(feedback);
  });
  $("run-button").addEventListener("click", () => { if (ready) setRunning(!get("running")); });
  $("step-button").addEventListener("click", () => {
    if (!ready || get("running")) return;
    core.step();
    render();
    say(`한 프레임 진행. 높이 ${(get("y") / 100).toFixed(1)} 픽셀.`);
  });
  $("jump-button").addEventListener("click", () => jump(1));
  $("reset-button").addEventListener("click", () => {
    if (!ready) return;
    core.reset();
    core.set_running(0);
    accumulator = 0;
    lastTime = null;
    droppedTicks = 0;
    notice = "처음 상태로 돌아왔습니다. 멈춘 상태에서 다시 시작할 수 있어요.";
    syncApplied();
    text("apply-feedback", "위치·진행 시간·규칙·기록을 초기 상태로 되돌렸습니다.");
    render();
    say("세계와 규칙을 초기 상태로 되돌렸습니다.");
  });
  document.addEventListener("keydown", (event) => {
    if (!ready || (event.code !== "Space" && event.key !== " ") || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
    const target = event.target;
    if (target instanceof Element && (target.isContentEditable || target.closest("input, textarea, select, button, a[href], summary, [contenteditable], [role=button]"))) return;
    event.preventDefault();
    if (event.repeat) return;
    jump(0);
  });
  document.addEventListener("visibilitychange", () => {
    if (!ready || !document.hidden) return;
    const wasRunning = Boolean(get("running"));
    core.set_running(0);
    lastTime = null;
    accumulator = 0;
    if (wasRunning) {
      notice = "다른 탭으로 이동해 자동으로 멈췄습니다. 실행을 누르면 이어집니다.";
      render();
      say(notice);
    }
  });
  window.addEventListener("resize", render);

  Object.defineProperty(window, "pureDemo", { value: Object.freeze({
    snapshot() {
      if (!ready) return Object.freeze({ ready: false });
      return Object.freeze({ ready: true, ...Object.fromEntries(Object.keys(field).map((name) => [name, get(name)])), draftDirty: isDirty(), droppedTicks, renderCount, wasmMemoryBytes: core.memory?.buffer.byteLength ?? null, logicalBytes: core.bytes(6), decisionTick: core.decision_meta(0), decisionRevision: core.decision_meta(1), decisionNodes: Object.freeze(nodes.map((node) => core.node_value(Number(node.dataset.node)))) });
    }
  }), writable: false, configurable: false });

  async function boot() {
    try {
      const response = await fetch("core.wasm", { cache: "no-store" });
      if (!response.ok) throw new Error(`core.wasm HTTP ${response.status}`);
      const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {});
      core = instance.exports;
      for (const name of ["reset", "set_running", "advance", "step", "input", "apply_rules", "get", "node_value", "decision_meta", "history_count", "history_get", "bytes"]) {
        if (typeof core[name] !== "function") throw new Error(`필요한 코어 함수가 없습니다: ${name}`);
      }
      core.reset();
      core.set_running(0);
      ready = true;
      $("rule-controls").disabled = false;
      for (const id of ["run-button", "step-button", "jump-button", "reset-button"]) $(id).disabled = false;
      $("workspace").setAttribute("aria-busy", "false");
      $("load-message").hidden = true;
      syncApplied();
      ["rules", "state", "decision", "history", "engine", "graph", "total"].forEach((name, i) => text(`bytes-${name}`, formatBytes(core.bytes(i))));
      render();
      say("준비되었습니다. 멈춘 상태입니다. 점프 버튼을 눌러보세요.");
      window.requestAnimationFrame(frame);
    } catch (error) {
      ready = false;
      $("workspace").setAttribute("aria-busy", "false");
      $("load-message").hidden = true;
      $("load-error").hidden = false;
      text("running-badge", "실행기 없음");
      text("error-detail", error instanceof Error ? error.message : String(error));
      for (const control of document.querySelectorAll("button, #rule-controls")) control.disabled = true;
    }
  }
  boot();
})();