import type { Project } from "./schema.ts"; export type HudMeter = { id: string; label: string; value: number; max: number; style?: "hearts" | "bar"; }; export type HudMessage = { objective?: string; counters?: string[]; meters?: HudMeter[]; overlay?: { title: string; body: string; button?: string } | null; }; export function normalizeHud(data: any): HudMessage { const text = (v: any, max = 180) => String(v ?? "").slice(0, max); const result: HudMessage = {}; if (typeof data?.objective === "string") result.objective = text(data.objective); if (Array.isArray(data?.counters)) result.counters = data.counters.slice(0, 4).map((v: any) => text(v, 60)); if (Array.isArray(data?.meters)) result.meters = data.meters.slice(0, 6).map((entry: any) => { const m = entry && typeof entry === "object" ? entry : {}; const max = Number.isFinite(m.max) && m.max > 0 ? m.max : 1; return { id: text(m.id, 60), label: text(m.label, 60), value: Math.max( 0, Math.min(max, Number.isFinite(m.value) ? m.value : 0), ), max, style: m.style === "hearts" ? "hearts" : "bar", }; }); if (data?.overlay === null) result.overlay = null; else if (data?.overlay) result.overlay = { title: text(data.overlay.title, 100), body: text(data.overlay.body, 400), button: text(data.overlay.button || "Ещё раз", 60), }; return result; } type Actions = { pause: (v: boolean) => void; paused: () => boolean; restart: () => void; move: (x: number, z: number) => void; attack: (v: boolean) => void; dash: () => void; }; const styles = ` .forma-presentation{container-type:inline-size;position:absolute;inset:0;z-index:25;pointer-events:none;color:#edddbb;font:15px Georgia,'Times New Roman',serif;text-shadow:0 2px 4px #000;--accent:#d84a36} .forma-presentation *{box-sizing:border-box}.forma-presentation button{pointer-events:auto;cursor:pointer;font:inherit;color:#edddbb;background:#1c110eeb;border:1px solid #88704b;padding:10px 16px;border-radius:2px;box-shadow:0 3px 10px #0005;text-shadow:none}.forma-presentation button:hover{background:#42221a;border-color:#e7c585}.forma-presentation button:focus-visible{outline:2px solid #f5d38a;outline-offset:3px} .fp-top{position:absolute;left:3%;right:3%;top:3%;display:flex;justify-content:space-between;gap:20px;align-items:flex-start}.fp-title{font-size:23px;font-weight:normal;letter-spacing:.02em}.fp-life{margin-top:8px}.fp-hearts{display:flex;gap:5px;color:var(--accent);font:29px Georgia;filter:drop-shadow(0 2px 1px #000)}.fp-hearts .empty{color:#39251f;-webkit-text-stroke:1px #967250}.fp-hearts-label{position:absolute;width:1px;height:1px;overflow:hidden} .fp-objective{position:absolute;left:30%;right:30%;top:3%;text-align:center;font-size:17px}.fp-counters{font-size:14px;color:#c5ad83;margin-top:7px}.fp-nav{display:flex;gap:7px}.fp-nav button{font:14px system-ui;padding:8px 12px} .fp-bottom{position:absolute;left:3%;right:3%;bottom:3%;display:flex;justify-content:space-between;align-items:flex-end;gap:24px}.fp-help{color:#d4bea0;max-width:550px;font:14px system-ui;line-height:1.8;background:#130c0bbd;padding:8px 13px;border-left:2px solid #705439}.fp-bars{width:170px}.fp-meter{margin-top:9px;font-size:15px}.fp-track{height:7px;background:#1a0d0b;border:1px solid #73523c;margin-top:6px;box-shadow:0 1px 5px #000}.fp-fill{height:100%;background:linear-gradient(90deg,#763125,#e09b56)} .fp-overlay{position:absolute;inset:0;display:grid;place-items:center;background:#0804048a;backdrop-filter:blur(2px);pointer-events:auto}.fp-overlay[hidden]{display:none}.fp-panel{max-height:96%;overflow:auto;width:min(470px,88%);text-align:center;padding:38px 36px 32px;background:linear-gradient(#251510f2,#100909f5);border:1px solid #9a744b;box-shadow:0 0 0 5px #140b08bb,0 24px 100px #000b}.fp-panel:before{content:'✦';display:block;color:#d29056;font-size:25px;margin-bottom:16px}.fp-panel h1{font-size:38px;font-weight:normal;margin:0 0 18px;color:#f2dcaf}.fp-panel p{font:16px/1.7 system-ui;color:#d0bca1;margin:0 0 24px;white-space:pre-line}.fp-panel button{font-size:18px;min-width:190px;padding:13px 24px;background:#702b20;border-color:#c18b56}.fp-panel .fp-sub{font:13px system-ui;margin-top:18px;color:#9b8267} .fp-flash{position:absolute;inset:0;box-shadow:inset 0 0 130px #bc211bc0;opacity:0;transition:opacity .3s}.fp-touch{display:none;position:absolute;inset:auto 4% 13%;justify-content:space-between;align-items:center}.fp-stick{width:100px;height:100px;border:1px solid #c5a47377;background:#130a0955;border-radius:50%;pointer-events:auto;touch-action:none;display:grid;place-items:center}.fp-stick i{width:42px;height:42px;background:#cdb48499;border:1px solid #ddc493;border-radius:50%}.fp-actions{display:flex;gap:12px}.fp-actions button{width:70px;height:70px;padding:0;border-radius:50%;touch-action:none} @media(pointer:coarse){.fp-touch{display:flex}.fp-help{display:none}.fp-bottom{bottom:3%}.fp-bars{margin-left:auto}}@media(max-width:900px){.fp-title{font-size:18px}.fp-objective{top:12%;left:25%;right:25%;font-size:14px}.fp-nav button{padding:7px;font-size:12px}.fp-help{font-size:12px;max-width:420px}.fp-panel h1{font-size:30px}.fp-panel{padding:22px}.fp-hearts{font-size:24px}} @container(max-width:900px){.fp-title{font-size:18px}.fp-objective{top:15%;left:25%;right:25%;font-size:14px}.fp-nav button{padding:7px;font-size:12px}.fp-help{font-size:11px;max-width:65%}.fp-panel h1{font-size:30px}.fp-panel{padding:22px}.fp-hearts{font-size:24px}} @media(prefers-reduced-motion:reduce){.fp-flash{transition:none}} `; export class RuntimePresentation { private root: HTMLDivElement; private actions: Actions; private start = true; private finished = false; private audio: AudioContext | null = null; private muted = false; private cleanup: (() => void)[] = []; private flashTimer: ReturnType | undefined; constructor( canvas: HTMLCanvasElement, config: NonNullable, actions: Actions, ) { this.actions = actions; if (!document.getElementById("forma-presentation-css")) { const s = document.createElement("style"); s.id = "forma-presentation-css"; s.textContent = styles; document.head.append(s); } this.root = document.createElement("div"); this.root.className = "forma-presentation"; if (/^#[0-9a-f]{6}$/i.test(config.accent || "")) this.root.style.setProperty("--accent", config.accent!); this.root.innerHTML = '
'; const q = (s: string) => this.root.querySelector(s) as HTMLElement; q(".fp-title").textContent = config.title || ""; q(".fp-help").textContent = config.instructions || ""; canvas.parentElement?.append(this.root); const audio = () => { try { this.audio ??= new AudioContext(); void this.audio.resume(); } catch {} }; q("[data-action=pause]").onclick = () => this.pause(!actions.paused()); q("[data-action=mute]").onclick = () => { audio(); this.muted = !this.muted; q("[data-action=mute]").textContent = this.muted ? "Звук: выкл" : "Звук: вкл"; }; q("[data-action=full]").onclick = () => { void canvas.parentElement?.requestFullscreen().catch(() => {}); }; q("[data-action=continue]").onclick = () => { audio(); this.resume(); }; const key = (e: KeyboardEvent) => { if (/INPUT|TEXTAREA|SELECT/.test((e.target as HTMLElement)?.tagName)) return; if (e.code === "Escape") { e.preventDefault(); if (!this.finished && !this.start) this.pause(!actions.paused()); } if (e.code === "KeyR" && (actions.paused() || this.finished)) { e.preventDefault(); this.finished = true; this.resume(); } if (e.code === "Enter" && !q(".fp-overlay").hidden) { e.preventDefault(); audio(); this.resume(); } }; window.addEventListener("keydown", key); this.cleanup.push(() => window.removeEventListener("keydown", key)); const blur = () => { actions.move(0, 0); actions.attack(false); if (!this.start && !this.finished) this.pause(true); }; window.addEventListener("blur", blur); this.cleanup.push(() => window.removeEventListener("blur", blur)); const stick = q(".fp-stick"), knob = q(".fp-stick i"); let pointer: number | null = null; const move = (e: PointerEvent) => { if (e.pointerId !== pointer || actions.paused()) return; const r = stick.getBoundingClientRect(); let x = (e.clientX - r.left - r.width / 2) / 36, z = -(e.clientY - r.top - r.height / 2) / 36; const l = Math.max(1, Math.hypot(x, z)); x /= l; z /= l; actions.move(x, z); knob.style.transform = `translate(${x * 26}px,${-z * 26}px)`; }; stick.onpointerdown = (e) => { pointer = e.pointerId; stick.setPointerCapture(pointer); move(e); }; stick.onpointermove = move; stick.onpointerup = stick.onpointercancel = () => { pointer = null; actions.move(0, 0); knob.style.transform = ""; }; const attack = q("[data-action=attack]"); attack.onpointerdown = (e) => { attack.setPointerCapture(e.pointerId); if (!actions.paused()) actions.attack(true); }; attack.onpointerup = attack.onpointercancel = () => actions.attack(false); q("[data-action=dash]").onpointerdown = () => { if (!actions.paused()) actions.dash(); }; if (config.start) { actions.pause(true); this.overlay(config.start.title, config.start.body, "Войти в арену"); q(".fp-sub").textContent = "Enter — начать"; } else { this.start = false; q(".fp-overlay").hidden = true; } } private overlay(title: string, body: string, button: string) { const q = (s: string) => this.root.querySelector(s) as HTMLElement; q(".fp-overlay").hidden = false; q("h1").textContent = title; q(".fp-panel p").textContent = body; q("[data-action=continue]").textContent = button; q(".fp-sub").textContent = "Enter — продолжить · R — заново"; } private resume() { if (this.finished) { this.actions.restart(); this.finished = false; } else this.actions.pause(false); this.start = false; (this.root.querySelector(".fp-overlay") as HTMLElement).hidden = true; ( this.root.querySelector("[data-action=pause]") as HTMLElement ).textContent = "Пауза"; } private pause(value: boolean) { if (this.finished || this.start) return; this.actions.pause(value); if (value) this.overlay("Пауза", "Арена подождёт.", "Продолжить"); else (this.root.querySelector(".fp-overlay") as HTMLElement).hidden = true; ( this.root.querySelector("[data-action=pause]") as HTMLElement ).textContent = value ? "Продолжить" : "Пауза"; } event(name: string, data: any) { if (name === "hud") { const h = normalizeHud(data); if (h.objective !== undefined) this.root.querySelector(".fp-objective span")!.textContent = h.objective; if (h.counters) this.root.querySelector(".fp-counters")!.textContent = h.counters.join(" · "); if (h.meters) { const life = this.root.querySelector(".fp-life")!, bars = this.root.querySelector(".fp-bars")!; life.replaceChildren(); bars.replaceChildren(); for (const m of h.meters) { const e = document.createElement("div"); e.className = "fp-meter"; if (m.style === "hearts") { e.className = "fp-hearts"; e.setAttribute("aria-label", `${m.label}: ${m.value} / ${m.max}`); for (let i = 0; i < Math.min(20, m.max); i++) { const s = document.createElement("span"); s.textContent = "♥"; if (i >= m.value) s.className = "empty"; e.append(s); } life.append(e); } else { e.textContent = m.label; const t = document.createElement("div"); t.className = "fp-track"; const f = document.createElement("div"); f.className = "fp-fill"; f.style.width = `${(100 * m.value) / m.max}%`; t.append(f); e.append(t); bars.append(e); } } } if (h.overlay) { this.finished = true; this.start = false; this.actions.pause(true); this.overlay( h.overlay.title, h.overlay.body, h.overlay.button || "Ещё раз", ); } else if (h.overlay === null && !this.start) { this.finished = false; (this.root.querySelector(".fp-overlay") as HTMLElement).hidden = true; } } else if (name === "feedback") { const f = this.root.querySelector(".fp-flash") as HTMLElement; f.style.opacity = "1"; clearTimeout(this.flashTimer); this.flashTimer = setTimeout(() => (f.style.opacity = "0"), 90); } else if (name === "sound" && !this.muted && this.audio) { const ctx = this.audio; const o = ctx.createOscillator(), g = ctx.createGain(); const t = ctx.currentTime, d = Math.max(0.02, Math.min(1, Number(data?.duration) || 0.1)); o.type = ["sine", "square", "triangle", "sawtooth"].includes(data?.type) ? data.type : "sine"; o.frequency.setValueAtTime( Math.max(30, Math.min(3000, Number(data?.frequency) || 180)), t, ); o.frequency.exponentialRampToValueAtTime( Math.max(30, Math.min(3000, Number(data?.endFrequency) || 60)), t + d, ); g.gain.setValueAtTime( Math.max(0.001, Math.min(0.1, Number(data?.gain) || 0.035)), t, ); g.gain.exponentialRampToValueAtTime(0.001, t + d); o.connect(g); g.connect(ctx.destination); o.start(t); o.stop(t + d + 0.02); o.onended = () => { o.disconnect(); g.disconnect(); }; } } dispose() { this.cleanup.forEach((f) => f()); clearTimeout(this.flashTimer); void this.audio?.close(); this.root.remove(); } }