aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/live-rules/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'prototypes/live-rules/web/app.js')
-rw-r--r--prototypes/live-rules/web/app.js294
1 files changed, 294 insertions, 0 deletions
diff --git a/prototypes/live-rules/web/app.js b/prototypes/live-rules/web/app.js
new file mode 100644
index 0000000..d8ca815
--- /dev/null
+++ b/prototypes/live-rules/web/app.js
@@ -0,0 +1,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();
+})();