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
+1
View File
@@ -3,6 +3,7 @@
/native/node_modules/ /native/node_modules/
/public/engine/ /public/engine/
/public/studio/ /public/studio/
/public/hosted-editor/
/public/build-targets/ /public/build-targets/
# Local projects, build results and toolchains # Local projects, build results and toolchains
+49
View File
@@ -0,0 +1,49 @@
# Fixed-camera games and runtime presentation
Forma supports fixed orthographic scenes that combine 3D geometry and transparent GLB sprite planes. This is optional: projects without these settings retain the existing camera, controls, and player UI.
## Camera and rendering
An entity can have `camera: { mode: "fixed", lookAt: [0, 0, 0], projection: "orthographic", orthoWidth: 24, aspect: 16 / 9 }`. The camera uses the entity's world position, faces `lookAt`, and preserves the specified aspect ratio with letterboxing. Omit `aspect` to fill the current viewport. The inspector exposes fixed mode, projection, width and look-at position. Perspective and follow/first-person cameras remain supported.
`settings.rendering` accepts `toneMapping`, `exposure` and `contrast`. Disabling tone mapping is useful for artwork that already contains lighting. Generated mesh materials accept `unlit`, `alpha` (01) and `doubleSided`; scripts can update them through `api.patch`. Imported GLB materials retain their authoring settings unless a material override is requested.
## Controls
`settings.controls` optionally maps `attack`, `jump`, `dash`, `sprint` and `reset` to arrays of browser `KeyboardEvent.code` values. Empty arrays disable a keyboard action. Mouse attack and pointer aiming retain the existing runtime input API. Movement remains WASD/arrows; a project's script may transform those axes into its camera basis. Dash/jump/reset also have one-frame pressed events.
## Presentation
Optional `settings.presentation` enables the same lightweight overlay in the editor and exported standalone player:
```json
{
"title": "Example game",
"accent": "#d74732",
"instructions": "WASD — move · J — attack · Space — dash",
"start": { "title": "Example game", "body": "Reach the exit." }
}
```
The start screen pauses simulation until the player continues. The overlay supplies pause/resume, fullscreen, sound, touch movement/attack/dash and keyboard restart. On restart it emits `resetPressed`; the project script owns resetting its gameplay state. Stop in the editor restores the original scene document.
Scripts can emit these presentation events:
```js
api.emit("hud", {
objective: "Wave 1 / 3",
counters: ["Enemies: 4", "Defeated: 0"],
meters: [
{ id: "life", label: "Health", value: 4, max: 5, style: "hearts" },
{ id: "dash", label: "Dash", value: 0.7, max: 1, style: "bar" }
]
});
api.emit("hud", { overlay: { title: "Victory", body: "You reached the exit.", button: "Play again" } });
api.emit("hud", { overlay: null }); // Clear an outcome overlay when resetting.
api.emit("feedback", {}); // Brief damage vignette.
api.emit("sound", { frequency: 520, endFrequency: 90, duration: 0.1, type: "triangle", gain: 0.025 });
```
Outcome overlays pause simulation. HUD text is assigned as text content, meter values and list lengths are bounded. Audio starts after a user gesture and can be muted. Runtime callbacks continue receiving these events, including during headless tests.
The concrete arena, artwork, combat, enemy AI and wave progression live in `projects/InfernalCourtyard`, not in the engine or default project templates.
+18
View File
@@ -0,0 +1,18 @@
# Browser-only editor export
Forma can publish its editor with a bundled starting project to any static HTTP host. It uses the same scene editor, inspector, scripts, play runtime, imports and archive exports as the local installation.
Build the engine, then export a JSON or packed .forma project:
```bash
npm run build
node --import tsx scripts/export-editor.ts PROJECT.forma OUTPUT_DIRECTORY
```
Serve OUTPUT_DIRECTORY over HTTP. The entry point is index.html. It loads project.forma.json and starts Editor with `initialProject` and `browserOnly: true`. No local service probe, MCP token, server-side files or local machine access is included.
A valid IndexedDB draft takes priority over the bundled starting project. Each visitor edits their own browser copy. If storage is unavailable or corrupt, startup falls back to the validated bundled project. The project menu exports .forma backups that include the GLB assets and scripts. Imported models are stored as embedded data in the browser draft.
The build panel defaults to web export when disconnected. Native builds can be downloaded as build kits; direct native compilation and the local MCP service still require the local Forma installation. The exporter includes the player and build-kit resources needed for these downloads.
Local startup still prioritizes a connected Forma service and otherwise falls back to the browser draft or an empty project. The startup tests cover these precedence rules, restored user edits, missing storage and isolation from the local service in browser-only mode.
+1 -1
View File
@@ -42,7 +42,7 @@ export function BuildPanel({
project: Project; project: Project;
connected: boolean; connected: boolean;
}) { }) {
const [target, setTarget] = useState("linux"), const [target, setTarget] = useState(connected ? "linux" : "web"),
[options, setOptions] = useState(() => { [options, setOptions] = useState(() => {
try { try {
return normalizeOptions( return normalizeOptions(
+84 -39
View File
@@ -69,7 +69,8 @@ import {
mimeFor, mimeFor,
} from "../engine/archive.ts"; } from "../engine/archive.ts";
import { FormaRuntime } from "../engine/runtime.ts"; import { FormaRuntime } from "../engine/runtime.ts";
import { loadDraft, saveDraft } from "./persistence.ts"; import { saveDraft } from "./persistence.ts";
import { loadEditorProject, type EditorStartupOptions } from "./startup.ts";
import { inspectModel } from "../engine/model.ts"; import { inspectModel } from "../engine/model.ts";
import "./editor.css"; import "./editor.css";
import { BuildPanel } from "./BuildPanel.tsx"; import { BuildPanel } from "./BuildPanel.tsx";
@@ -277,7 +278,7 @@ function Modal({
</div> </div>
); );
} }
export default function Editor() { export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) {
const [project, setProject] = useState<Project | null>(null), const [project, setProject] = useState<Project | null>(null),
[selected, setSelected] = useState<string | null>(null), [selected, setSelected] = useState<string | null>(null),
[connection, setConnection] = useState<any>(null), [connection, setConnection] = useState<any>(null),
@@ -405,26 +406,10 @@ export default function Editor() {
let alive = true; let alive = true;
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
(async () => { (async () => {
let p: Project | null = null, const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly });
status: any = null;
try {
const r = await fetch("/api/status", {
signal: AbortSignal.timeout(1800),
});
if (r.ok) {
const d: any = await r.json();
if (d.forma) {
status = d;
p = (await call("/api/project")).project;
}
}
} catch {}
if (!p)
try {
p = await loadDraft();
} catch {}
if (!alive) return; if (!alive) return;
store.current = new ProjectStore(p || defaultProject()); store.current = new ProjectStore(startingProject);
setCached(restored);
connected.current = status; connected.current = status;
setConnection(status); setConnection(status);
setProject(store.current.project); setProject(store.current.project);
@@ -507,7 +492,7 @@ export default function Editor() {
events.current?.close(); events.current?.close();
clearTimeout(saveTimer.current); clearTimeout(saveTimer.current);
}; };
}, [accept, addLog, call]); }, [accept, addLog, call, initialProject, browserOnly]);
useEffect(() => { useEffect(() => {
if (!ready || !canvas.current) return; if (!ready || !canvas.current) return;
const r = new FormaRuntime(canvas.current, { const r = new FormaRuntime(canvas.current, {
@@ -1162,13 +1147,13 @@ export default function Editor() {
</div> </div>
)} )}
</div> </div>
<span className="save-status"> <span className="save-status" title={browserOnly ? "Правки сохраняются только в этом браузере. Скачай .forma через меню проекта, чтобы перенести их." : undefined}>
<i /> <i />
{connection {connection
? "На диске" ? "На диске"
: cached : cached
? "Автокопия сохранена" ? "Автокопия сохранена"
: "Локальный проект"} : browserOnly ? "Проект в браузере" : "Локальный проект"}
</span> </span>
<button className="command-search" onClick={() => setModal("search")}> <button className="command-search" onClick={() => setModal("search")}>
<Search size={14} /> <Search size={14} />
@@ -1451,7 +1436,7 @@ export default function Editor() {
</button> </button>
</> </>
)} )}
{playing && ( {playing && !project.settings.presentation && (
<> <>
<div className="game-hud"> <div className="game-hud">
<div> <div>
@@ -1651,20 +1636,39 @@ export default function Editor() {
<span>{c.color}</span> <span>{c.color}</span>
</div> </div>
</div> </div>
{["roughness", "metallic", "emissive"].map((k, i) => ( {["roughness", "metallic", "emissive", "alpha"].map(
<div className="property" key={k}> (k, i) => (
<label> <div className="property" key={k}>
{["Шероховатость", "Металличность", "Свечение"][i]} <label>
</label> {
<Num [
label={k} "Шероховатость",
value={c[k] || 0} "Металличность",
min={0} "Свечение",
max={k === "emissive" ? 5 : 1} "Непрозрачность",
onChange={(v) => void componentField(type, k, v)} ][i]
/> }
</div> </label>
))} <Num
label={k}
value={c[k] ?? (k === "alpha" ? 1 : 0)}
min={0}
max={k === "emissive" ? 5 : 1}
onChange={(v) => void componentField(type, k, v)}
/>
</div>
),
)}
<label className="check-row">
<input
type="checkbox"
checked={c.unlit || false}
onChange={(e) =>
void componentField(type, "unlit", e.target.checked)
}
/>
Без освещения (2D / VFX)
</label>
{node.components.mesh?.type === "model" && ( {node.components.mesh?.type === "model" && (
<label className="check-row"> <label className="check-row">
<input <input
@@ -1801,9 +1805,50 @@ export default function Editor() {
} }
> >
<option value="follow">Следование</option> <option value="follow">Следование</option>
<option value="fixed">Фиксированная</option>
<option value="firstPerson">От первого лица</option> <option value="firstPerson">От первого лица</option>
</select> </select>
</div> </div>
<div className="property">
<label>Проекция</label>
<select
aria-label="Проекция камеры"
value={c.projection || "perspective"}
onChange={(e) =>
void setComponent(type, {
...c,
projection: e.target.value,
orthoWidth: c.orthoWidth || 20,
})
}
>
<option value="perspective">Перспектива</option>
<option value="orthographic">Ортографическая</option>
</select>
</div>
{c.projection === "orthographic" && (
<div className="property">
<label>Ширина кадра</label>
<Num
label="Ширина ортокамеры"
value={c.orthoWidth || 20}
min={0.1}
max={200}
onChange={(v) =>
void componentField(type, "orthoWidth", v)
}
/>
</div>
)}
{c.mode === "fixed" && (
<Vector
label="Точка взгляда"
value={c.lookAt || [0, 0, 0]}
onChange={(v) =>
void componentField(type, "lookAt", v)
}
/>
)}
<div className="property"> <div className="property">
<label>Цель</label> <label>Цель</label>
{entitySelect( {entitySelect(
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Editor from './Editor.tsx';
import { validateProject } from '../engine/schema.ts';
const root = document.getElementById('root')!;
async function start() {
const response = await fetch('./project.forma.json');
if (!response.ok) throw Error('Не удалось загрузить проект Forma');
const project = await response.json();
validateProject(project);
createRoot(root).render(<Editor initialProject={project} browserOnly />);
}
void start().catch((error) => {
root.replaceChildren();
const message = document.createElement('p');
message.style.cssText = 'padding:32px;font:16px/1.6 system-ui';
message.textContent = 'Не удалось открыть проект. Обнови страницу. ' + String(error.message || error);
root.append(message);
});
+35
View File
@@ -0,0 +1,35 @@
import { type Project, clone, validateProject } from '../engine/schema.ts';
import { defaultProject } from '../engine/templates.ts';
import { loadDraft } from './persistence.ts';
export interface EditorStartupOptions { initialProject?: Project; browserOnly?: boolean; }
export async function loadEditorProject(
options: EditorStartupOptions = {},
dependencies: { request: typeof fetch; draft: () => Promise<Project | null> } = { request: fetch, draft: loadDraft },
) {
if (!options.browserOnly) {
try {
const response = await dependencies.request('/api/status', { signal: AbortSignal.timeout(1800) });
if (response.ok) {
const status = await response.json();
if (status.forma) {
const response = await dependencies.request('/api/project');
if (!response.ok) throw Error('Project unavailable');
const { project } = await response.json();
validateProject(project);
return { project: project as Project, status, restored: false };
}
}
} catch {}
}
try {
const project = await dependencies.draft();
if (project) {
validateProject(project);
return { project, status: null, restored: true };
}
} catch {}
const project = clone(options.initialProject || defaultProject());
validateProject(project);
return { project, status: null, restored: false };
}
+18
View File
@@ -11,6 +11,24 @@ async function boot() {
if (!response.ok) throw Error(`Project: HTTP ${response.status}`); if (!response.ok) throw Error(`Project: HTTP ${response.status}`);
const project: Project = await response.json(); const project: Project = await response.json();
validateProject(project); 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>`; 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; document.getElementById("title")!.textContent = project.name;
let firstPerson = false; 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 { workerSource } from "./script-host.ts";
import { inspectModel } from "./model.ts"; import { inspectModel } from "./model.ts";
import { CharacterMotor } from "./character.ts"; import { CharacterMotor } from "./character.ts";
import { RuntimePresentation } from "./presentation.ts";
export interface RuntimeCallbacks { export interface RuntimeCallbacks {
select?: (id: string | null) => void; select?: (id: string | null) => void;
transform?: (id: string, t: Entity["transform"]) => void; transform?: (id: string, t: Entity["transform"]) => void;
@@ -95,6 +96,13 @@ export class FormaRuntime {
{ to: B.AnimationGroup; from: B.AnimationGroup[]; time: number } { to: B.AnimationGroup; from: B.AnimationGroup[]; time: number }
>(); >();
private flashes = new Map<string, 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( constructor(
public canvas: HTMLCanvasElement, public canvas: HTMLCanvasElement,
public callbacks: RuntimeCallbacks = {}, public callbacks: RuntimeCallbacks = {},
@@ -138,9 +146,12 @@ export class FormaRuntime {
) )
e.preventDefault(); e.preventDefault();
if (!this.keys.has(e.code)) { if (!this.keys.has(e.code)) {
if (e.code === "Space") this.requestAction("jump"); if (this.binding("jump", ["Space"]).includes(e.code))
if (e.code === "KeyE") this.requestAction("dash"); this.requestAction("jump");
if (e.code === "KeyR") this.requestAction("reset"); 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); this.keys.add(e.code);
}, },
@@ -335,11 +346,14 @@ export class FormaRuntime {
this.shadow.filteringQuality = B.ShadowGenerator.QUALITY_LOW; this.shadow.filteringQuality = B.ShadowGenerator.QUALITY_LOW;
this.shadow.bias = 0.0005; this.shadow.bias = 0.0005;
this.shadow.normalBias = 0.04; this.shadow.normalBias = 0.04;
scene.imageProcessingConfiguration.toneMappingEnabled = true; scene.imageProcessingConfiguration.toneMappingEnabled =
p.settings.rendering?.toneMapping !== false;
scene.imageProcessingConfiguration.toneMappingType = scene.imageProcessingConfiguration.toneMappingType =
B.ImageProcessingConfiguration.TONEMAPPING_ACES; B.ImageProcessingConfiguration.TONEMAPPING_ACES;
scene.imageProcessingConfiguration.exposure = 1.12; scene.imageProcessingConfiguration.exposure =
scene.imageProcessingConfiguration.contrast = 1.05; p.settings.rendering?.exposure ?? 1.12;
scene.imageProcessingConfiguration.contrast =
p.settings.rendering?.contrast ?? 1.05;
const lines: B.Vector3[][] = []; const lines: B.Vector3[][] = [];
for (let i = -35; i <= 35; i++) { for (let i = -35; i <= 35; i++) {
lines.push([new B.Vector3(i, -0.55, -35), new B.Vector3(i, -0.55, 35)]); 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.scaling.copyFromFloats(...n.transform.scale);
root.setEnabled(n.enabled); root.setEnabled(n.enabled);
root.computeWorldMatrix(true); 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) { private removeNode(id: string) {
const node = this.nodes.get(id); const node = this.nodes.get(id);
@@ -614,6 +645,13 @@ export class FormaRuntime {
mat.roughness = c.roughness ?? 0.8; mat.roughness = c.roughness ?? 0.8;
mat.metallic = c.metallic ?? 0; mat.metallic = c.metallic ?? 0;
mat.emissiveColor = mat.albedoColor.scale(c.emissive || 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; for (const mesh of meshes) mesh.material = mat;
} }
} }
@@ -777,6 +815,8 @@ export class FormaRuntime {
this.runId = uid("run"); this.runId = uid("run");
this.camera.detachControl(); this.camera.detachControl();
this.scene.activeCamera = this.gameCamera; this.scene.activeCamera = this.gameCamera;
this.updateFixedCamera();
this.updateProjection();
this.grid.setEnabled(false); this.grid.setEnabled(false);
this.select(null); this.select(null);
this.keys.clear(); this.keys.clear();
@@ -789,6 +829,33 @@ export class FormaRuntime {
)?.components.camera; )?.components.camera;
this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 }; this.look = { yaw: fp?.yaw ?? 0, pitch: fp?.pitch ?? 0 };
this.currentAnims.clear(); 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.startWorker();
this.callbacks.mode?.(true); this.callbacks.mode?.(true);
this.log("info", "Запуск " + this.runId); this.log("info", "Запуск " + this.runId);
@@ -797,6 +864,8 @@ export class FormaRuntime {
stop(project?: Project) { stop(project?: Project) {
return this.enqueue(async () => { return this.enqueue(async () => {
this.stopWorker(); this.stopWorker();
this.presentation?.dispose();
this.presentation = null;
this.playing = false; this.playing = false;
this.paused = false; this.paused = false;
this.world?.free(); this.world?.free();
@@ -924,6 +993,7 @@ export class FormaRuntime {
this.applyTransform(n); this.applyTransform(n);
if (Number.isFinite(c.yaw)) this.look = { yaw: c.yaw, pitch: 0 }; if (Number.isFinite(c.yaw)) this.look = { yaw: c.yaw, pitch: 0 };
} else if (c.type === "event") { } else if (c.type === "event") {
this.presentation?.event(String(c.name), c.data);
this.callbacks.event?.(String(c.name), c.data, c.id); this.callbacks.event?.(String(c.name), c.data, c.id);
} else if (c.type === "move" && n) { } else if (c.type === "move" && n) {
if ( if (
@@ -1155,13 +1225,20 @@ export class FormaRuntime {
(this.keys.has("KeyW") || this.keys.has("ArrowUp") ? 1 : 0) - (this.keys.has("KeyW") || this.keys.has("ArrowUp") ? 1 : 0) -
(this.keys.has("KeyS") || this.keys.has("ArrowDown") ? 1 : 0), (this.keys.has("KeyS") || this.keys.has("ArrowDown") ? 1 : 0),
attack: attack:
this.input.attack || this.touch.attack || this.keys.has("Space"), this.input.attack ||
jump: this.touch.jump || this.keys.has("Space"), this.touch.attack ||
dash: this.touch.dash || this.keys.has("KeyE"), 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: sprint:
this.touch.sprint || this.touch.sprint ||
this.keys.has("ShiftLeft") || this.binding("sprint", ["ShiftLeft", "ShiftRight"]).some((k) =>
this.keys.has("ShiftRight"), this.keys.has(k),
),
jumpPressed: this.actionQueue.has("jump"), jumpPressed: this.actionQueue.has("jump"),
dashPressed: this.actionQueue.has("dash"), dashPressed: this.actionQueue.has("dash"),
resetPressed: this.actionQueue.has("reset"), resetPressed: this.actionQueue.has("reset"),
@@ -1192,7 +1269,9 @@ export class FormaRuntime {
target = c?.targetId target = c?.targetId
? this.nodes.get(c.targetId)?.getAbsolutePosition() ? this.nodes.get(c.targetId)?.getAbsolutePosition()
: B.Vector3.Zero(); : 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 player = this.state.find((n) => n.id === c.targetId);
const data = player?.components.data || {}; const data = player?.components.data || {};
const speed = data.speed || 0; const speed = data.speed || 0;
@@ -1226,6 +1305,7 @@ export class FormaRuntime {
this.gameCamera.fov = c?.fov || 0.72; this.gameCamera.fov = c?.fov || 0.72;
} }
} }
if (this.playing) this.updateProjection();
for (const [id, b] of this.blends) { for (const [id, b] of this.blends) {
b.time += dt; b.time += dt;
const t = Math.min(1, b.time / 0.16); const t = Math.min(1, b.time / 0.16);
@@ -1267,10 +1347,47 @@ export class FormaRuntime {
dispose() { dispose() {
this.disposed = true; this.disposed = true;
this.stopWorker(); this.stopWorker();
this.presentation?.dispose();
this.presentation = null;
this.cleanup.forEach((fn) => fn()); this.cleanup.forEach((fn) => fn());
this.world?.free(); this.world?.free();
this.world = null; this.world = null;
this.scene?.dispose(); this.scene?.dispose();
this.engine.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; ambient: number;
shadows: boolean; shadows: boolean;
renderScale: number; 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 { export interface Command {
@@ -259,6 +269,20 @@ export function validateProject(p: Project) {
throw Error("Не найден скрипт " + c.script.scriptId); throw Error("Не найден скрипт " + c.script.scriptId);
if (c.material?.color && !color(c.material.color)) if (c.material?.color && !color(c.material.color))
throw Error("Цвет должен быть #RRGGBB"); 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))) if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0)))
throw Error("Размеры должны быть положительными"); throw Error("Размеры должны быть положительными");
if ( if (
@@ -338,7 +362,13 @@ export function remapEntityReferences(
} }
export const componentDefaults: Record<string, Component> = { export const componentDefaults: Record<string, Component> = {
mesh: { type: "box", size: [1, 1, 1] }, 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 }, collider: { shape: "box", size: [1, 1, 1], radius: 0.4 },
rigidbody: { type: "fixed", mass: 1, restitution: 0.1 }, rigidbody: { type: "fixed", mass: 1, restitution: 0.1 },
character: { gravity: 24, autostep: 0.25 }, character: { gravity: 24, autostep: 0.25 },
+11
View File
@@ -27,6 +27,17 @@ await build({
define: { "process.env.NODE_ENV": '"production"' }, define: { "process.env.NODE_ENV": '"production"' },
logLevel: "warning", logLevel: "warning",
}); });
await mkdir("public/hosted-editor", { recursive: true });
await build({
entryPoints: ["editor/hosted.tsx"],
bundle: true,
format: "esm",
target: "es2022",
minify: true,
outfile: "public/hosted-editor/editor.js",
define: { "process.env.NODE_ENV": '"production"' },
logLevel: "warning",
});
await writeFile( await writeFile(
"public/studio/index.html", "public/studio/index.html",
'<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma Engine</title><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/studio/editor.css"></head><body><div id="root"></div><script type="module" src="/studio/editor.js"></script></body></html>', '<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma Engine</title><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/studio/editor.css"></head><body><div id="root"></div><script type="module" src="/studio/editor.js"></script></body></html>',
+26
View File
@@ -0,0 +1,26 @@
import { readFile, writeFile, mkdir, cp } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { unzipSync } from 'fflate';
import { unpackProject, projectArchive, decodeData } from '../engine/archive.ts';
const [projectFile, outputDirectory] = process.argv.slice(2);
if (!projectFile || !outputDirectory) throw Error('Usage: node --import tsx scripts/export-editor.ts PROJECT.forma[.json] OUTPUT_DIRECTORY');
const source = path.resolve(projectFile), out = path.resolve(outputDirectory);
const engine = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const project = unpackProject(new Uint8Array(await readFile(source)));
const archive = await projectArchive(project, async uri => uri.startsWith('data:') ? decodeData(uri) : new Uint8Array(await readFile(path.resolve(path.dirname(source), uri))));
await mkdir(out, { recursive: true });
for (const [name, bytes] of Object.entries(unzipSync(archive))) {
if (name !== 'project.forma.json' && !name.startsWith('assets/')) continue;
const target = path.join(out, name);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, bytes);
}
for (const [sourceDir, targetDir] of [['hosted-editor', 'studio'], ['engine', 'engine'], ['build-targets', 'build-targets']]) {
await cp(path.join(engine, 'public', sourceDir), path.join(out, targetDir), { recursive: true });
}
await cp(path.join(engine, 'public/favicon.svg'), path.join(out, 'favicon.svg'));
const title = project.name.replace(/[<>&"']/g, '');
await writeFile(path.join(out, 'index.html'), `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Forma — ${title}</title><meta name="description" content="Редактор Forma с открытым проектом. Редактируй сцену, модели и скрипты; запускай игру и сохраняй проект."><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/studio/editor.css"></head><body><div id="root"><p style="padding:32px;font:16px system-ui">Открываю проект Forma…</p></div><script type="module" src="/studio/editor.js"></script></body></html>`);
console.log(`Browser editor exported to ${out}; project: ${project.name}, revision ${project.revision}.`);
+24
View File
@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeHud } from "../engine/presentation.ts";
test("HUD bounds script values and handles malformed meter entries", () => {
const h = normalizeHud({
objective: "x".repeat(300),
counters: [1, 2, 3, 4, 5],
meters: [
null,
{ id: "life", max: 5, value: 12, style: "hearts" },
{ max: 0, value: NaN },
{ max: 10, value: -3 },
],
});
assert.equal(h.objective!.length, 180);
assert.equal(h.counters!.length, 4);
assert.equal(h.meters![0].value, 0);
assert.equal(h.meters![1].value, 5);
assert.equal(h.meters![1].style, "hearts");
assert.equal(h.meters![2].max, 1);
assert.equal(h.meters![3].value, 0);
assert.deepEqual(normalizeHud(null), {});
assert.equal(normalizeHud({ overlay: null }).overlay, null);
});
+92
View File
@@ -220,3 +220,95 @@ test(
} }
}, },
); );
test(
"fixed orthographic camera, alpha events and legacy projection restore",
{ timeout: 10000 },
async () => {
const r = runtime(),
p = defaultProject(true);
p.settings.rendering = { toneMapping: false };
p.scripts = [
{
id: "fade",
name: "Fade",
fields: {},
source:
'({start(api){api.patch("visual",{components:{material:{alpha:.3,unlit:true}}})}})',
},
];
activeScene(p).entities = [
entity(
"Camera",
{
camera: {
mode: "fixed",
projection: "orthographic",
orthoWidth: 24,
aspect: 16 / 9,
lookAt: [0, 0, 0],
},
},
[10, 20, 30],
"camera",
),
entity(
"Visual",
{
mesh: { type: "box", size: [1, 1, 1] },
material: { color: "#ff8800", alpha: 1 },
script: { scriptId: "fade" },
},
[0, 0, 0],
"visual",
),
];
try {
await r.play(p);
for (
let i = 0;
i < 60 &&
r.state.find((n) => n.id === "visual")?.components.material.alpha !==
0.3;
i++
)
await wait(50);
assert.equal(r.gameCamera.mode, 1);
assert.deepEqual(r.gameCamera.position.asArray(), [10, 20, 30]);
assert.equal(r.gameCamera.orthoLeft, -12);
assert.equal(r.gameCamera.orthoTop, 6.75);
assert.equal(r.gameCamera.viewport.height, 0.75);
assert.equal(
r.scene.imageProcessingConfiguration.toneMappingEnabled,
false,
);
const mat: any = r.nodes.get("visual")!.getChildMeshes()[0].material;
assert.equal(mat.alpha, 0.3);
assert.equal(mat.unlit, true);
await r.stop(p);
assert.equal(
r.state.find((n) => n.id === "visual")!.components.material.alpha,
1,
);
const legacy = defaultProject(true);
activeScene(legacy).entities = [
entity(
"Camera",
{ camera: { offset: [0, 13, -10] } },
[0, 13, -10],
"camera",
),
];
await r.play(legacy);
await wait(50);
assert.equal(r.gameCamera.mode, 0);
assert.equal(r.gameCamera.viewport.height, 1);
assert.equal(
r.scene.imageProcessingConfiguration.toneMappingEnabled,
true,
);
} finally {
r.dispose();
}
},
);
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadEditorProject } from '../editor/startup.ts';
import { defaultProject } from '../engine/templates.ts';
const neverRequest: typeof fetch = async () => { throw Error('Unexpected network request'); };
test('hosted editor loads its bundled project without contacting a local engine', async()=>{
let requests=0;
const seed=defaultProject(true);seed.name='Bundled courtyard';
const result=await loadEditorProject({browserOnly:true,initialProject:seed},{request:async()=>{requests++;return new Response();},draft:async()=>null});
assert.equal(requests,0);assert.equal(result.project.name,seed.name);assert.equal(result.status,null);assert.equal(result.restored,false);
result.project.name='edited';assert.equal(seed.name,'Bundled courtyard');
});
test('hosted editor retains user edits across reloads instead of replacing them with its seed',async()=>{
const seed=defaultProject(true),saved=defaultProject(true);saved.name='User edits';
const result=await loadEditorProject({browserOnly:true,initialProject:seed},{request:neverRequest,draft:async()=>saved});
assert.equal(result.project.name,'User edits');assert.equal(result.restored,true);
});
test('unavailable or corrupt browser storage still opens the bundled project',async()=>{
const seed=defaultProject(true);seed.name='Seed';
for(const draft of [async()=>{throw Error('Storage unavailable');},async()=>({invalid:true} as any)]){
const result=await loadEditorProject({browserOnly:true,initialProject:seed},{request:neverRequest,draft});assert.equal(result.project.name,'Seed');
}
});
test('local editor still gives the connected Forma project priority over browser drafts',async()=>{
const project=defaultProject(true);project.name='Local engine project';
const status={forma:true,canUndo:true};
const result=await loadEditorProject({}, {request:async(url)=>Response.json(url==='/api/status'?status:{project}),draft:async()=>{throw Error('Must not read draft');}});
assert.equal(result.project.name,project.name);assert.deepEqual(result.status,status);
});