Add browser-only editor export and 2.5D runtime presentation
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
/native/node_modules/
|
||||
/public/engine/
|
||||
/public/studio/
|
||||
/public/hosted-editor/
|
||||
/public/build-targets/
|
||||
|
||||
# Local projects, build results and toolchains
|
||||
|
||||
@@ -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` (0–1) 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.
|
||||
@@ -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.
|
||||
@@ -42,7 +42,7 @@ export function BuildPanel({
|
||||
project: Project;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const [target, setTarget] = useState("linux"),
|
||||
const [target, setTarget] = useState(connected ? "linux" : "web"),
|
||||
[options, setOptions] = useState(() => {
|
||||
try {
|
||||
return normalizeOptions(
|
||||
|
||||
+84
-39
@@ -69,7 +69,8 @@ import {
|
||||
mimeFor,
|
||||
} from "../engine/archive.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 "./editor.css";
|
||||
import { BuildPanel } from "./BuildPanel.tsx";
|
||||
@@ -277,7 +278,7 @@ function Modal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function Editor() {
|
||||
export default function Editor({ initialProject, browserOnly = false }: EditorStartupOptions = {}) {
|
||||
const [project, setProject] = useState<Project | null>(null),
|
||||
[selected, setSelected] = useState<string | null>(null),
|
||||
[connection, setConnection] = useState<any>(null),
|
||||
@@ -405,26 +406,10 @@ export default function Editor() {
|
||||
let alive = true;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
(async () => {
|
||||
let p: Project | null = null,
|
||||
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 {}
|
||||
const { project: startingProject, status, restored } = await loadEditorProject({ initialProject, browserOnly });
|
||||
if (!alive) return;
|
||||
store.current = new ProjectStore(p || defaultProject());
|
||||
store.current = new ProjectStore(startingProject);
|
||||
setCached(restored);
|
||||
connected.current = status;
|
||||
setConnection(status);
|
||||
setProject(store.current.project);
|
||||
@@ -507,7 +492,7 @@ export default function Editor() {
|
||||
events.current?.close();
|
||||
clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [accept, addLog, call]);
|
||||
}, [accept, addLog, call, initialProject, browserOnly]);
|
||||
useEffect(() => {
|
||||
if (!ready || !canvas.current) return;
|
||||
const r = new FormaRuntime(canvas.current, {
|
||||
@@ -1162,13 +1147,13 @@ export default function Editor() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="save-status">
|
||||
<span className="save-status" title={browserOnly ? "Правки сохраняются только в этом браузере. Скачай .forma через меню проекта, чтобы перенести их." : undefined}>
|
||||
<i />
|
||||
{connection
|
||||
? "На диске"
|
||||
: cached
|
||||
? "Автокопия сохранена"
|
||||
: "Локальный проект"}
|
||||
: browserOnly ? "Проект в браузере" : "Локальный проект"}
|
||||
</span>
|
||||
<button className="command-search" onClick={() => setModal("search")}>
|
||||
<Search size={14} />
|
||||
@@ -1451,7 +1436,7 @@ export default function Editor() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{playing && (
|
||||
{playing && !project.settings.presentation && (
|
||||
<>
|
||||
<div className="game-hud">
|
||||
<div>
|
||||
@@ -1651,20 +1636,39 @@ export default function Editor() {
|
||||
<span>{c.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
{["roughness", "metallic", "emissive"].map((k, i) => (
|
||||
<div className="property" key={k}>
|
||||
<label>
|
||||
{["Шероховатость", "Металличность", "Свечение"][i]}
|
||||
</label>
|
||||
<Num
|
||||
label={k}
|
||||
value={c[k] || 0}
|
||||
min={0}
|
||||
max={k === "emissive" ? 5 : 1}
|
||||
onChange={(v) => void componentField(type, k, v)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{["roughness", "metallic", "emissive", "alpha"].map(
|
||||
(k, i) => (
|
||||
<div className="property" key={k}>
|
||||
<label>
|
||||
{
|
||||
[
|
||||
"Шероховатость",
|
||||
"Металличность",
|
||||
"Свечение",
|
||||
"Непрозрачность",
|
||||
][i]
|
||||
}
|
||||
</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" && (
|
||||
<label className="check-row">
|
||||
<input
|
||||
@@ -1801,9 +1805,50 @@ export default function Editor() {
|
||||
}
|
||||
>
|
||||
<option value="follow">Следование</option>
|
||||
<option value="fixed">Фиксированная</option>
|
||||
<option value="firstPerson">От первого лица</option>
|
||||
</select>
|
||||
</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">
|
||||
<label>Цель</label>
|
||||
{entitySelect(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -27,6 +27,17 @@ await build({
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
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(
|
||||
"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>',
|
||||
|
||||
@@ -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}.`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user