Files

316 lines
15 KiB
TypeScript

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<typeof setTimeout> | undefined;
constructor(
canvas: HTMLCanvasElement,
config: NonNullable<Project["settings"]["presentation"]>,
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 =
'<div class="fp-flash"></div><div class="fp-top"><div><div class="fp-title"></div><div class="fp-life"></div></div><nav class="fp-nav"><button data-action="pause">Пауза</button><button data-action="mute" aria-label="Включить или выключить звук">Звук: вкл</button><button data-action="full" aria-label="Полный экран">⛶</button></nav></div><div class="fp-objective"><span></span><div class="fp-counters"></div></div><div class="fp-bottom"><div class="fp-help"></div><div class="fp-bars"></div></div><div class="fp-touch"><div class="fp-stick" aria-label="Движение"><i></i></div><div class="fp-actions"><button data-action="dash">Рывок</button><button data-action="attack">Удар</button></div></div><div class="fp-overlay"><section class="fp-panel" role="dialog" aria-modal="true"><h1></h1><p></p><button data-action="continue"></button><div class="fp-sub"></div></section></div>';
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();
}
}