332 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|