Add browser-only editor export and 2.5D runtime presentation

This commit is contained in:
emil28092005
2026-09-12 00:05:19 +03:00
parent a25ed40805
commit 1a0bf725fa
16 changed files with 883 additions and 53 deletions
+18
View File
@@ -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;
+315
View File
@@ -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
View File
@@ -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
View File
@@ -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 },