Add browser-only editor export and 2.5D runtime presentation
This commit is contained in:
@@ -11,6 +11,24 @@ async function boot() {
|
||||
if (!response.ok) throw Error(`Project: HTTP ${response.status}`);
|
||||
const project: Project = await response.json();
|
||||
validateProject(project);
|
||||
if (project.settings.presentation) {
|
||||
ui.innerHTML = "";
|
||||
const runtime = new FormaRuntime(canvas, {
|
||||
log: (level, message) => {
|
||||
if (level === "error") {
|
||||
const error = document.createElement("div");
|
||||
error.id = "error";
|
||||
error.textContent = message;
|
||||
ui.replaceChildren(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
window.addEventListener("pagehide", () => runtime.dispose(), {
|
||||
once: true,
|
||||
});
|
||||
await runtime.play(project);
|
||||
return;
|
||||
}
|
||||
ui.innerHTML = `<div class="hud"><div><strong id="title"></strong><small id="hint"></small></div><nav><button id="pause">Пауза</button><button id="restart">Перезапустить</button></nav></div><div class="touch"><div id="stick" aria-label="Джойстик"><i></i></div><div class="actions"><button id="jump" aria-label="Прыжок">↑</button><button id="action" aria-label="Действие">A</button></div></div><div id="error" hidden></div>`;
|
||||
document.getElementById("title")!.textContent = project.name;
|
||||
let firstPerson = false;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
+129
-12
@@ -13,6 +13,7 @@ import {
|
||||
import { workerSource } from "./script-host.ts";
|
||||
import { inspectModel } from "./model.ts";
|
||||
import { CharacterMotor } from "./character.ts";
|
||||
import { RuntimePresentation } from "./presentation.ts";
|
||||
export interface RuntimeCallbacks {
|
||||
select?: (id: string | null) => void;
|
||||
transform?: (id: string, t: Entity["transform"]) => void;
|
||||
@@ -95,6 +96,13 @@ export class FormaRuntime {
|
||||
{ to: B.AnimationGroup; from: B.AnimationGroup[]; time: number }
|
||||
>();
|
||||
private flashes = new Map<string, number>();
|
||||
private presentation: RuntimePresentation | null = null;
|
||||
private binding(
|
||||
action: "attack" | "jump" | "dash" | "sprint" | "reset",
|
||||
defaults: string[],
|
||||
) {
|
||||
return this.document?.settings.controls?.[action] ?? defaults;
|
||||
}
|
||||
constructor(
|
||||
public canvas: HTMLCanvasElement,
|
||||
public callbacks: RuntimeCallbacks = {},
|
||||
@@ -138,9 +146,12 @@ export class FormaRuntime {
|
||||
)
|
||||
e.preventDefault();
|
||||
if (!this.keys.has(e.code)) {
|
||||
if (e.code === "Space") this.requestAction("jump");
|
||||
if (e.code === "KeyE") this.requestAction("dash");
|
||||
if (e.code === "KeyR") this.requestAction("reset");
|
||||
if (this.binding("jump", ["Space"]).includes(e.code))
|
||||
this.requestAction("jump");
|
||||
if (this.binding("dash", ["KeyE"]).includes(e.code))
|
||||
this.requestAction("dash");
|
||||
if (this.binding("reset", ["KeyR"]).includes(e.code))
|
||||
this.requestAction("reset");
|
||||
}
|
||||
this.keys.add(e.code);
|
||||
},
|
||||
@@ -335,11 +346,14 @@ export class FormaRuntime {
|
||||
this.shadow.filteringQuality = B.ShadowGenerator.QUALITY_LOW;
|
||||
this.shadow.bias = 0.0005;
|
||||
this.shadow.normalBias = 0.04;
|
||||
scene.imageProcessingConfiguration.toneMappingEnabled = true;
|
||||
scene.imageProcessingConfiguration.toneMappingEnabled =
|
||||
p.settings.rendering?.toneMapping !== false;
|
||||
scene.imageProcessingConfiguration.toneMappingType =
|
||||
B.ImageProcessingConfiguration.TONEMAPPING_ACES;
|
||||
scene.imageProcessingConfiguration.exposure = 1.12;
|
||||
scene.imageProcessingConfiguration.contrast = 1.05;
|
||||
scene.imageProcessingConfiguration.exposure =
|
||||
p.settings.rendering?.exposure ?? 1.12;
|
||||
scene.imageProcessingConfiguration.contrast =
|
||||
p.settings.rendering?.contrast ?? 1.05;
|
||||
const lines: B.Vector3[][] = [];
|
||||
for (let i = -35; i <= 35; i++) {
|
||||
lines.push([new B.Vector3(i, -0.55, -35), new B.Vector3(i, -0.55, 35)]);
|
||||
@@ -435,6 +449,23 @@ export class FormaRuntime {
|
||||
root.scaling.copyFromFloats(...n.transform.scale);
|
||||
root.setEnabled(n.enabled);
|
||||
root.computeWorldMatrix(true);
|
||||
const c = n.components.material;
|
||||
if (c && (n.components.mesh?.type !== "model" || c.override)) {
|
||||
for (const mesh of root.getChildMeshes()) {
|
||||
const mat = mesh.material;
|
||||
if (mat instanceof B.PBRMaterial) {
|
||||
mat.albedoColor = B.Color3.FromHexString(c.color || "#91a697");
|
||||
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
|
||||
mat.alpha = c.alpha ?? 1;
|
||||
mat.unlit = c.unlit === true;
|
||||
mat.transparencyMode =
|
||||
mat.alpha < 1
|
||||
? B.PBRMaterial.PBRMATERIAL_ALPHABLEND
|
||||
: B.PBRMaterial.PBRMATERIAL_OPAQUE;
|
||||
mat.backFaceCulling = c.doubleSided !== true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private removeNode(id: string) {
|
||||
const node = this.nodes.get(id);
|
||||
@@ -614,6 +645,13 @@ export class FormaRuntime {
|
||||
mat.roughness = c.roughness ?? 0.8;
|
||||
mat.metallic = c.metallic ?? 0;
|
||||
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 0);
|
||||
mat.alpha = c.alpha ?? 1;
|
||||
mat.unlit = c.unlit === true;
|
||||
mat.transparencyMode =
|
||||
mat.alpha < 1
|
||||
? B.PBRMaterial.PBRMATERIAL_ALPHABLEND
|
||||
: B.PBRMaterial.PBRMATERIAL_OPAQUE;
|
||||
mat.backFaceCulling = c.doubleSided !== true;
|
||||
for (const mesh of meshes) mesh.material = mat;
|
||||
}
|
||||
}
|
||||
@@ -777,6 +815,8 @@ export class FormaRuntime {
|
||||
this.runId = uid("run");
|
||||
this.camera.detachControl();
|
||||
this.scene.activeCamera = this.gameCamera;
|
||||
this.updateFixedCamera();
|
||||
this.updateProjection();
|
||||
this.grid.setEnabled(false);
|
||||
this.select(null);
|
||||
this.keys.clear();
|
||||
@@ -789,6 +829,33 @@ export class FormaRuntime {
|
||||
)?.components.camera;
|
||||
this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 };
|
||||
this.currentAnims.clear();
|
||||
if (!this.options.headless && p.settings.presentation) {
|
||||
this.presentation?.dispose();
|
||||
this.presentation = new RuntimePresentation(
|
||||
this.canvas,
|
||||
p.settings.presentation,
|
||||
{
|
||||
pause: (value) => {
|
||||
this.paused = value;
|
||||
this.releaseInput();
|
||||
},
|
||||
paused: () => this.paused,
|
||||
restart: () => {
|
||||
this.releaseInput();
|
||||
this.requestAction("reset");
|
||||
this.paused = false;
|
||||
},
|
||||
move: (x, z) => {
|
||||
this.touch.x = x;
|
||||
this.touch.z = z;
|
||||
},
|
||||
attack: (value) => {
|
||||
this.touch.attack = value;
|
||||
},
|
||||
dash: () => this.requestAction("dash"),
|
||||
},
|
||||
);
|
||||
}
|
||||
this.startWorker();
|
||||
this.callbacks.mode?.(true);
|
||||
this.log("info", "Запуск " + this.runId);
|
||||
@@ -797,6 +864,8 @@ export class FormaRuntime {
|
||||
stop(project?: Project) {
|
||||
return this.enqueue(async () => {
|
||||
this.stopWorker();
|
||||
this.presentation?.dispose();
|
||||
this.presentation = null;
|
||||
this.playing = false;
|
||||
this.paused = false;
|
||||
this.world?.free();
|
||||
@@ -924,6 +993,7 @@ export class FormaRuntime {
|
||||
this.applyTransform(n);
|
||||
if (Number.isFinite(c.yaw)) this.look = { yaw: c.yaw, pitch: 0 };
|
||||
} else if (c.type === "event") {
|
||||
this.presentation?.event(String(c.name), c.data);
|
||||
this.callbacks.event?.(String(c.name), c.data, c.id);
|
||||
} else if (c.type === "move" && n) {
|
||||
if (
|
||||
@@ -1155,13 +1225,20 @@ export class FormaRuntime {
|
||||
(this.keys.has("KeyW") || this.keys.has("ArrowUp") ? 1 : 0) -
|
||||
(this.keys.has("KeyS") || this.keys.has("ArrowDown") ? 1 : 0),
|
||||
attack:
|
||||
this.input.attack || this.touch.attack || this.keys.has("Space"),
|
||||
jump: this.touch.jump || this.keys.has("Space"),
|
||||
dash: this.touch.dash || this.keys.has("KeyE"),
|
||||
this.input.attack ||
|
||||
this.touch.attack ||
|
||||
this.binding("attack", ["Space"]).some((k) => this.keys.has(k)),
|
||||
jump:
|
||||
this.touch.jump ||
|
||||
this.binding("jump", ["Space"]).some((k) => this.keys.has(k)),
|
||||
dash:
|
||||
this.touch.dash ||
|
||||
this.binding("dash", ["KeyE"]).some((k) => this.keys.has(k)),
|
||||
sprint:
|
||||
this.touch.sprint ||
|
||||
this.keys.has("ShiftLeft") ||
|
||||
this.keys.has("ShiftRight"),
|
||||
this.binding("sprint", ["ShiftLeft", "ShiftRight"]).some((k) =>
|
||||
this.keys.has(k),
|
||||
),
|
||||
jumpPressed: this.actionQueue.has("jump"),
|
||||
dashPressed: this.actionQueue.has("dash"),
|
||||
resetPressed: this.actionQueue.has("reset"),
|
||||
@@ -1192,7 +1269,9 @@ export class FormaRuntime {
|
||||
target = c?.targetId
|
||||
? this.nodes.get(c.targetId)?.getAbsolutePosition()
|
||||
: B.Vector3.Zero();
|
||||
if (target && c?.mode === "firstPerson") {
|
||||
if (c?.mode === "fixed") {
|
||||
this.updateFixedCamera();
|
||||
} else if (target && c?.mode === "firstPerson") {
|
||||
const player = this.state.find((n) => n.id === c.targetId);
|
||||
const data = player?.components.data || {};
|
||||
const speed = data.speed || 0;
|
||||
@@ -1226,6 +1305,7 @@ export class FormaRuntime {
|
||||
this.gameCamera.fov = c?.fov || 0.72;
|
||||
}
|
||||
}
|
||||
if (this.playing) this.updateProjection();
|
||||
for (const [id, b] of this.blends) {
|
||||
b.time += dt;
|
||||
const t = Math.min(1, b.time / 0.16);
|
||||
@@ -1267,10 +1347,47 @@ export class FormaRuntime {
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.stopWorker();
|
||||
this.presentation?.dispose();
|
||||
this.presentation = null;
|
||||
this.cleanup.forEach((fn) => fn());
|
||||
this.world?.free();
|
||||
this.world = null;
|
||||
this.scene?.dispose();
|
||||
this.engine.dispose();
|
||||
}
|
||||
private updateFixedCamera() {
|
||||
const entity = this.state.find((n) => n.enabled && n.components.camera);
|
||||
const c = entity?.components.camera;
|
||||
if (c?.mode !== "fixed") return;
|
||||
this.gameCamera.position.copyFrom(
|
||||
this.nodes.get(entity!.id)?.getAbsolutePosition() ||
|
||||
B.Vector3.FromArray(entity!.transform.position),
|
||||
);
|
||||
this.gameCamera.setTarget(B.Vector3.FromArray(c.lookAt || [0, 0, 0]));
|
||||
}
|
||||
private updateProjection() {
|
||||
const c = this.state.find((n) => n.enabled && n.components.camera)
|
||||
?.components.camera;
|
||||
const cam = this.gameCamera;
|
||||
cam.mode =
|
||||
c?.projection === "orthographic"
|
||||
? B.Camera.ORTHOGRAPHIC_CAMERA
|
||||
: B.Camera.PERSPECTIVE_CAMERA;
|
||||
const screen =
|
||||
this.engine.getRenderWidth() / Math.max(1, this.engine.getRenderHeight());
|
||||
const aspect =
|
||||
Number.isFinite(c?.aspect) && c?.aspect > 0 ? c!.aspect : screen;
|
||||
cam.viewport = c?.aspect
|
||||
? screen > aspect
|
||||
? new B.Viewport((1 - aspect / screen) / 2, 0, aspect / screen, 1)
|
||||
: new B.Viewport(0, (1 - screen / aspect) / 2, 1, screen / aspect)
|
||||
: new B.Viewport(0, 0, 1, 1);
|
||||
if (cam.mode === B.Camera.ORTHOGRAPHIC_CAMERA) {
|
||||
const w = Math.max(0.1, c?.orthoWidth || 20);
|
||||
cam.orthoLeft = -w / 2;
|
||||
cam.orthoRight = w / 2;
|
||||
cam.orthoTop = w / aspect / 2;
|
||||
cam.orthoBottom = -w / aspect / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -58,6 +58,16 @@ export interface Project {
|
||||
ambient: number;
|
||||
shadows: boolean;
|
||||
renderScale: number;
|
||||
rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number };
|
||||
controls?: Partial<
|
||||
Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]>
|
||||
>;
|
||||
presentation?: {
|
||||
title?: string;
|
||||
instructions?: string;
|
||||
start?: { title: string; body: string };
|
||||
accent?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface Command {
|
||||
@@ -259,6 +269,20 @@ export function validateProject(p: Project) {
|
||||
throw Error("Не найден скрипт " + c.script.scriptId);
|
||||
if (c.material?.color && !color(c.material.color))
|
||||
throw Error("Цвет должен быть #RRGGBB");
|
||||
if (
|
||||
c.material?.alpha !== undefined &&
|
||||
(!Number.isFinite(c.material.alpha) ||
|
||||
c.material.alpha < 0 ||
|
||||
c.material.alpha > 1)
|
||||
)
|
||||
throw Error("Прозрачность материала должна быть от 0 до 1");
|
||||
if (
|
||||
c.camera?.projection === "orthographic" &&
|
||||
(!Number.isFinite(c.camera.orthoWidth) || c.camera.orthoWidth <= 0)
|
||||
)
|
||||
throw Error("Ширина ортографической камеры должна быть положительной");
|
||||
if (c.camera?.lookAt && !vector(c.camera.lookAt))
|
||||
throw Error("Некорректная цель камеры");
|
||||
if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0)))
|
||||
throw Error("Размеры должны быть положительными");
|
||||
if (
|
||||
@@ -338,7 +362,13 @@ export function remapEntityReferences(
|
||||
}
|
||||
export const componentDefaults: Record<string, Component> = {
|
||||
mesh: { type: "box", size: [1, 1, 1] },
|
||||
material: { color: "#91a697", roughness: 0.8, metallic: 0 },
|
||||
material: {
|
||||
color: "#91a697",
|
||||
roughness: 0.8,
|
||||
metallic: 0,
|
||||
alpha: 1,
|
||||
unlit: false,
|
||||
},
|
||||
collider: { shape: "box", size: [1, 1, 1], radius: 0.4 },
|
||||
rigidbody: { type: "fixed", mass: 1, restitution: 0.1 },
|
||||
character: { gravity: 24, autostep: 0.25 },
|
||||
|
||||
Reference in New Issue
Block a user