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(); textures = new Map(); 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 = {}, ) { 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(); } }