Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user