Files
2026-09-12 04:58:59 +03:00

332 lines
10 KiB
TypeScript

import * as B from "@babylonjs/core";
import type { Asset, Entity, Project } from "./schema.ts";
import { frameAt, type SpriteFrame } from "./two-d.ts";
const whiteFrame: SpriteFrame = {
name: "0",
x: 0,
y: 0,
width: 100,
height: 100,
pivot: [0.5, 0.5],
};
export function spriteQuad(
frame: SpriteFrame,
width: number,
height: number,
ppu: number,
size?: number[],
flipX = false,
flipY = false,
) {
const w = size?.[0] ?? frame.width / ppu,
h = size?.[1] ?? frame.height / ppu;
const x = -(flipX ? 1 - frame.pivot[0] : frame.pivot[0]) * w,
y = -(flipY ? frame.pivot[1] : 1 - frame.pivot[1]) * h;
let u0 = frame.x / width,
u1 = (frame.x + frame.width) / width,
v0 = 1 - (frame.y + frame.height) / height,
v1 = 1 - frame.y / height;
if (flipX) [u0, u1] = [u1, u0];
if (flipY) [v0, v1] = [v1, v0];
return {
positions: [x, y, 0, x + w, y, 0, x + w, y + h, 0, x, y + h, 0],
indices: [0, 1, 2, 0, 2, 3],
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
uvs: [u0, v0, u1, v0, u1, v1, u0, v1],
};
}
interface Visual {
mesh: B.Mesh;
material: B.PBRMaterial;
asset?: Asset;
node: Entity;
frame: number;
animation?: { name: string; clip: any; time: number; preview: boolean };
preview?: boolean;
configuredFrame?: number;
}
export class Graphics2D {
private debug?: B.LinesMesh;
visuals = new Map<string, Visual>();
textures = new Map<string, B.Texture>();
constructor(
private scene: B.Scene,
private onEvent?: (name: string, data: any, id: string) => void,
) {}
create(node: Entity, project: Project, root: B.TransformNode) {
const c = node.components.sprite || node.components.tilemap;
if (!c) return [];
const asset = project.assets.find(
(a) => a.id === c.assetId && a.kind === "image",
);
const mesh = new B.Mesh(node.id + "_2d", this.scene),
material = new B.PBRMaterial(node.id + "_2d_material", this.scene);
mesh.parent = root;
mesh.metadata = { entityId: node.id };
mesh.material = material;
material.unlit = c.lit !== true;
material.backFaceCulling = false;
material.metallic = 0;
material.roughness = 1;
material.transparencyMode = B.PBRMaterial.PBRMATERIAL_ALPHABLEND;
if (asset?.uri) {
const key = asset.id + "|" + asset.uri + "|" + asset.image?.filter;
let tex = this.textures.get(key);
if (!tex) {
tex = new B.Texture(
asset.uri,
this.scene,
true,
true,
asset.image?.filter === "nearest"
? B.Texture.NEAREST_SAMPLINGMODE
: B.Texture.BILINEAR_SAMPLINGMODE,
);
tex.hasAlpha = true;
tex.wrapU = tex.wrapV = B.Texture.CLAMP_ADDRESSMODE;
this.textures.set(key, tex);
}
material.albedoTexture = tex;
material.useAlphaFromAlbedoTexture = true;
}
this.visuals.set(node.id, { mesh, material, asset, node, frame: -1 });
this.update(node, true);
return [mesh];
}
remove(id: string) {
const v = this.visuals.get(id);
if (v) {
v.mesh.dispose();
v.material.dispose(false, false);
this.visuals.delete(id);
}
}
update(node: Entity, force = false) {
const v = this.visuals.get(node.id);
if (!v) return;
v.node = node;
const c = node.components.sprite || node.components.tilemap;
v.material.albedoColor = B.Color3.FromHexString(c.color || "#ffffff");
v.material.alpha = c.alpha ?? 1;
v.material.unlit = c.lit !== true;
v.mesh.computeWorldMatrix(true);
const y = v.mesh.absolutePosition.y;
// Preserve depth testing against 3D meshes; transparent sprites use explicit ordering.
v.mesh.alphaIndex =
(c.layer || 0) * 1e6 +
(c.order || 0) * 10 -
(c.sortY ? Math.atan(y) / Math.PI : 0);
if (node.components.tilemap) {
if (force) this.tileGeometry(v);
return;
}
const requested = c.frame || 0;
if (!v.animation && (!v.preview || requested !== v.configuredFrame))
this.setFrame(v, requested, force);
else if (force) this.setFrame(v, v.frame, true);
v.configuredFrame = requested;
}
private setFrame(v: Visual, index: number, force = false) {
if (!force && v.frame === index) return;
v.frame = index;
const im = v.asset?.image,
frame = im?.frames[index] || im?.frames[0] || whiteFrame,
c = v.node.components.sprite;
const data = spriteQuad(
frame,
im?.width || 100,
im?.height || 100,
im?.pixelsPerUnit || 100,
c.size,
c.flipX,
c.flipY,
);
const vd = new B.VertexData();
Object.assign(vd, data);
vd.applyToMesh(v.mesh, true);
}
private tileGeometry(v: Visual) {
const c = v.node.components.tilemap,
im = v.asset?.image,
data = {
positions: [] as number[],
indices: [] as number[],
normals: [] as number[],
uvs: [] as number[],
};
for (const cell of c.cells) {
const frame = im?.frames[cell.frame] || whiteFrame,
q = spriteQuad(
{ ...frame, pivot: [0, 1] },
im?.width || 100,
im?.height || 100,
im?.pixelsPerUnit || 100,
c.tileSize,
);
const base = data.positions.length / 3;
for (let i = 0; i < q.positions.length; i += 3) {
q.positions[i] += cell.x * c.tileSize[0];
q.positions[i + 1] += cell.y * c.tileSize[1];
}
data.positions.push(...q.positions);
data.normals.push(...q.normals);
data.uvs.push(...q.uvs);
data.indices.push(...q.indices.map((i) => i + base));
}
// Empty maps still have a pickable editor extent supplied by the XY grid.
const vd = new B.VertexData();
Object.assign(vd, data);
vd.applyToMesh(v.mesh, true);
v.mesh.isVisible = !!c.cells.length;
}
play(id: string, name: string, loop?: boolean, preview = false) {
const v = this.visuals.get(id),
config = v?.node.components.spriteAnimator;
const clip = config?.clips.find((c: any) => c.name === name);
if (!v || !clip) return false;
if (v.animation?.name === name && !preview) return true;
v.animation = {
name,
clip: { ...clip, ...(loop !== undefined ? { loop } : {}) },
time: 0,
preview,
};
this.setFrame(v, clip.frames[0]);
return true;
}
start() {
for (const [id, v] of this.visuals) {
v.animation = undefined;
v.preview = false;
if (v.node.components.sprite)
this.setFrame(v, v.node.components.sprite.frame || 0);
const autoplay = v.node.components.spriteAnimator?.autoplay;
if (autoplay) this.play(id, autoplay);
}
}
tick(
dt: number,
playing: boolean,
paused: boolean,
physics: Record<string, any> = {},
) {
if (paused) return;
for (const [id, v] of this.visuals) {
this.update(v.node);
const config = v.node.components.spriteAnimator,
p = physics[id];
if (playing && config?.autoStates && p) {
const state =
!p.grounded && v.node.components.character2d?.mode !== "topDown"
? "jump"
: Math.hypot(p.velocity?.x || 0, p.velocity?.y || 0) > 0.1
? "run"
: "idle";
if (config.clips.some((c: any) => c.name === state))
this.play(id, state);
if (v.node.components.sprite && Math.abs(p.velocity.x) > 0.1) {
const flip = p.velocity.x < 0;
if (v.node.components.sprite.flipX !== flip) {
v.node.components.sprite.flipX = flip;
this.setFrame(v, v.frame, true);
}
}
}
const a = v.animation;
if (!a || (!playing && !a.preview)) continue;
a.time += dt;
this.setFrame(v, frameAt(a.clip, a.time));
if (
a.clip.loop === false &&
a.time >= a.clip.frames.length / a.clip.fps
) {
v.animation = undefined;
v.preview = true;
this.onEvent?.("animationend2d", { clip: a.name }, id);
}
}
}
snapshot() {
return Object.fromEntries(
[...this.visuals]
.filter(([, v]) => v.node.components.sprite)
.map(([id, v]) => [
id,
{
frame: v.frame,
clip: v.animation?.name || null,
playing: !!v.animation,
},
]),
);
}
outline(node?: Entity, root?: B.TransformNode) {
this.debug?.dispose();
this.debug = undefined;
if (!node || !root) return;
const c = node.components.collider2d,
t = node.components.tilemap;
if (!c && !t && !node.components.sprite) return;
let points: number[][] = [];
if (t)
points = [
[0, 0],
[t.width * t.tileSize[0], 0],
[t.width * t.tileSize[0], t.height * t.tileSize[1]],
[0, t.height * t.tileSize[1]],
];
else if (!c) {
const box = this.visuals.get(node.id)!.mesh.getBoundingInfo().boundingBox;
points = [
[box.minimum.x, box.minimum.y],
[box.maximum.x, box.minimum.y],
[box.maximum.x, box.maximum.y],
[box.minimum.x, box.maximum.y],
];
} else if (c.shape === "polygon") points = c.points;
else if (c.shape === "circle" || c.shape === "capsule") {
const radius = c.radius || 0.5,
shaft =
c.shape === "capsule"
? Math.max(0, (c.height || 1.8) / 2 - radius)
: 0;
for (let i = 0; i < 48; i++) {
const a = (i / 48) * Math.PI * 2;
points.push([
Math.cos(a) * radius,
Math.sin(a) * radius + (Math.sin(a) >= 0 ? shaft : -shaft),
]);
}
} else {
const [w, h] = c.size || [1, 1];
points = [
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
];
}
const offset = t || !c ? [0, 0] : c.offset || [0, 0],
vectors = [...points, points[0]].map(
(p) => new B.Vector3(p[0] + offset[0], p[1] + offset[1], 0.005),
);
this.debug = B.MeshBuilder.CreateLines(
"Collider2D outline",
{ points: vectors },
this.scene,
);
this.debug.parent = root;
this.debug.color = B.Color3.FromHexString(
c?.sensor ? "#ffd274" : "#68e6bd",
);
this.debug.isPickable = false;
this.debug.renderingGroupId = 1;
}
dispose() {
this.debug?.dispose();
for (const id of this.visuals.keys()) this.remove(id);
for (const t of this.textures.values()) t.dispose();
this.textures.clear();
}
}