Files
prosperon/scene.cm
2026-01-01 22:01:58 -06:00

651 lines
16 KiB
Plaintext

// scene.cm - Retained Scene Graph with Dirty Tracking
//
// Provides retained nodes with:
// - Persistent identity and dirty flags
// - Cached geometry data
// - World transform computation
// - Automatic dirty propagation
var blob_mod = use('blob')
// Dirty flag bitmask
var DIRTY = {
NONE: 0,
TRANSFORM: 1,
CONTENT: 2,
CHILDREN: 4,
ALL: 7
}
// Base node prototype - all nodes inherit from this
var BaseNode = {
id: null,
type: 'node',
dirty: DIRTY.ALL,
parent: null,
layer: 0,
// Local properties
pos: null,
opacity: 1,
// Computed world properties (updated during scene.update)
world_pos: null,
world_tint: null,
world_opacity: 1,
bounds: null,
// Backend-specific handle (for playdate sprites, etc.)
backend_handle: null,
mark_dirty: function(flags) {
this.dirty |= flags
},
set_pos: function(x, y) {
if (!this.pos) this.pos = {x: 0, y: 0}
if (this.pos.x == x && this.pos.y == y) return this
this.pos.x = x
this.pos.y = y
this.dirty |= DIRTY.TRANSFORM
return this
},
set_opacity: function(o) {
if (this.opacity == o) return this
this.opacity = o
this.dirty |= DIRTY.CONTENT
return this
}
}
// Sprite node prototype
var SpriteNode = {
type: 'sprite',
// Source properties
image: null,
width: 1,
height: 1,
anchor: null,
color: null,
uv_rect: null,
slice: null,
tile: null,
material: null,
// Geometry cache
geom_cache: null,
set_image: function(img) {
if (this.image == img) return this
this.image = img
this.dirty |= DIRTY.CONTENT
if (this.geom_cache) this.geom_cache.texture_key = img
return this
},
set_size: function(w, h) {
if (this.width == w && this.height == h) return this
this.width = w
this.height = h
this.dirty |= DIRTY.CONTENT
return this
},
set_anchor: function(x, y) {
if (!this.anchor) this.anchor = {x: 0, y: 0}
if (this.anchor.x == x && this.anchor.y == y) return this
this.anchor.x = x
this.anchor.y = y
this.dirty |= DIRTY.TRANSFORM
return this
},
set_color: function(r, g, b, a) {
if (!this.color) this.color = {r: 1, g: 1, b: 1, a: 1}
if (this.color.r == r && this.color.g == g && this.color.b == b && this.color.a == a) return this
this.color.r = r
this.color.g = g
this.color.b = b
this.color.a = a
this.dirty |= DIRTY.CONTENT
return this
},
rebuild_geometry: function() {
if (this.slice) return this._build_9slice_geom()
if (this.tile) return this._build_tiled_geom()
return this._build_quad_geom()
},
_build_quad_geom: function() {
if (!this.geom_cache) {
this.geom_cache = {
verts: null,
indices: null,
vert_count: 0,
index_count: 0,
texture_key: this.image
}
}
var verts = new blob_mod(128) // 4 verts * 8 floats * 4 bytes
var indices = new blob_mod(12) // 6 indices * 2 bytes
var ax = this.anchor ? this.anchor.x : 0
var ay = this.anchor ? this.anchor.y : 0
var x = this.world_pos.x - this.width * ax
var y = this.world_pos.y - this.height * ay
var w = this.width
var h = this.height
var c = this.color || {r: 1, g: 1, b: 1, a: 1}
var alpha = c.a * this.world_opacity
var uv = this.uv_rect || {x: 0, y: 0, width: 1, height: 1}
var u0 = uv.x
var v0 = uv.y
var u1 = uv.x + uv.width
var v1 = uv.y + uv.height
// v0: bottom-left
verts.wf(x); verts.wf(y)
verts.wf(u0); verts.wf(v1)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(alpha)
// v1: bottom-right
verts.wf(x + w); verts.wf(y)
verts.wf(u1); verts.wf(v1)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(alpha)
// v2: top-right
verts.wf(x + w); verts.wf(y + h)
verts.wf(u1); verts.wf(v0)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(alpha)
// v3: top-left
verts.wf(x); verts.wf(y + h)
verts.wf(u0); verts.wf(v0)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(alpha)
indices.w16(0); indices.w16(1); indices.w16(2)
indices.w16(0); indices.w16(2); indices.w16(3)
this.geom_cache.verts = stone(verts)
this.geom_cache.indices = stone(indices)
this.geom_cache.vert_count = 4
this.geom_cache.index_count = 6
this.geom_cache.texture_key = this.image
},
_build_9slice_geom: function() {
// TODO: Implement 9-slice geometry building
this._build_quad_geom()
},
_build_tiled_geom: function() {
// TODO: Implement tiled geometry building
this._build_quad_geom()
}
}
// Tilemap node prototype
var TilemapNode = {
type: 'tilemap',
tile_size: null,
tiles: null,
offset: null,
geom_cache: null,
set_tile: function(x, y, image) {
if (!this.tiles) this.tiles = []
if (!this.tiles[x]) this.tiles[x] = []
if (this.tiles[x][y] == image) return this
this.tiles[x][y] = image
this.dirty |= DIRTY.CONTENT
return this
},
rebuild_geometry: function() {
if (!this.geom_cache) {
this.geom_cache = {
verts: null,
indices: null,
vert_count: 0,
index_count: 0,
batches: []
}
}
if (!this.tiles) {
this.geom_cache.vert_count = 0
this.geom_cache.index_count = 0
this.geom_cache.batches = []
return
}
// Count tiles and group by texture
var tile_count = 0
var texture_map = {}
var ts = this.tile_size || {x: 1, y: 1}
var off = this.offset || {x: 0, y: 0}
for (var x = 0; x < this.tiles.length; x++) {
if (!this.tiles[x]) continue
for (var y = 0; y < this.tiles[x].length; y++) {
var t = this.tiles[x][y]
if (!t) continue
if (!texture_map[t]) texture_map[t] = []
texture_map[t].push({x: x, y: y})
tile_count++
}
}
if (tile_count == 0) {
this.geom_cache.vert_count = 0
this.geom_cache.index_count = 0
this.geom_cache.batches = []
return
}
var verts = new blob_mod(tile_count * 4 * 32)
var indices = new blob_mod(tile_count * 6 * 2)
var vert_offset = 0
var index_offset = 0
var batches = []
for (var tex in texture_map) {
var batch_start = index_offset / 2
var tiles_list = texture_map[tex]
for (var i = 0; i < tiles_list.length; i++) {
var tile = tiles_list[i]
var wx = this.world_pos.x + (tile.x + off.x) * ts.x
var wy = this.world_pos.y + (tile.y + off.y) * ts.y
var tw = ts.x
var th = ts.y
// 4 vertices
verts.wf(wx); verts.wf(wy); verts.wf(0); verts.wf(1)
verts.wf(1); verts.wf(1); verts.wf(1); verts.wf(this.world_opacity)
verts.wf(wx + tw); verts.wf(wy); verts.wf(1); verts.wf(1)
verts.wf(1); verts.wf(1); verts.wf(1); verts.wf(this.world_opacity)
verts.wf(wx + tw); verts.wf(wy + th); verts.wf(1); verts.wf(0)
verts.wf(1); verts.wf(1); verts.wf(1); verts.wf(this.world_opacity)
verts.wf(wx); verts.wf(wy + th); verts.wf(0); verts.wf(0)
verts.wf(1); verts.wf(1); verts.wf(1); verts.wf(this.world_opacity)
// 6 indices
var base = vert_offset
indices.w16(base); indices.w16(base + 1); indices.w16(base + 2)
indices.w16(base); indices.w16(base + 2); indices.w16(base + 3)
vert_offset += 4
index_offset += 12
}
batches.push({
texture: tex,
start_index: batch_start,
index_count: tiles_list.length * 6
})
}
this.geom_cache.verts = stone(verts)
this.geom_cache.indices = stone(indices)
this.geom_cache.vert_count = vert_offset
this.geom_cache.index_count = index_offset / 2
this.geom_cache.batches = batches
}
}
// Text node prototype
var TextNode = {
type: 'text',
text: '',
font: 'fonts/dos',
size: 16,
mode: 'bitmap',
color: null,
anchor: null,
outline_width: 0,
outline_color: null,
measured: null,
geom_cache: null,
set_text: function(t) {
if (this.text == t) return this
this.text = t
this.dirty |= DIRTY.CONTENT
return this
},
set_font: function(f, s, m) {
if (this.font == f && this.size == s && this.mode == m) return this
this.font = f
if (s != null) this.size = s
if (m != null) this.mode = m
this.dirty |= DIRTY.CONTENT
return this
},
rebuild_geometry: function(font_system) {
// Text geometry is built by the backend using font_system
// We just mark that we need rebuild
if (!this.geom_cache) {
this.geom_cache = {
verts: null,
indices: null,
vert_count: 0,
index_count: 0,
font_texture: null
}
}
}
}
// Group node prototype
var GroupNode = {
type: 'group',
children: null,
effects: null,
space: '2d',
scissor: null,
bounds: null,
add_child: function(node) {
if (!this.children) this.children = []
node.parent = this
this.children.push(node)
this.dirty |= DIRTY.CHILDREN
return this
},
remove_child: function(node) {
if (!this.children) return this
var idx = this.children.indexOf(node)
if (idx >= 0) {
this.children.splice(idx, 1)
node.parent = null
this.dirty |= DIRTY.CHILDREN
}
return this
},
clear_children: function() {
if (!this.children) return this
for (var c of this.children) {
c.parent = null
}
this.children = []
this.dirty |= DIRTY.CHILDREN
return this
}
}
// Particle node prototype
var ParticleNode = {
type: 'particles',
image: null,
width: 1,
height: 1,
particles: null,
max_particles: 1000,
geom_cache: null,
init: function() {
if (!this.geom_cache) {
this.geom_cache = {
verts: null,
indices: null,
active_count: 0,
capacity: this.max_particles
}
}
// Pre-allocate index buffer (never changes)
var indices = new blob_mod(this.max_particles * 6 * 2)
for (var i = 0; i < this.max_particles; i++) {
var base = i * 4
indices.w16(base)
indices.w16(base + 1)
indices.w16(base + 2)
indices.w16(base)
indices.w16(base + 2)
indices.w16(base + 3)
}
this.geom_cache.indices = stone(indices)
return this
},
rebuild_geometry: function() {
if (!this.particles || this.particles.length == 0) {
if (this.geom_cache) this.geom_cache.active_count = 0
return
}
if (!this.geom_cache) this.init()
var verts = new blob_mod(this.max_particles * 4 * 32)
var count = 0
for (var i = 0; i < this.particles.length && count < this.max_particles; i++) {
var p = this.particles[i]
var hw = this.width * (p.scale || 1) * 0.5
var hh = this.height * (p.scale || 1) * 0.5
var px = this.world_pos.x + (p.pos ? p.pos.x : 0)
var py = this.world_pos.y + (p.pos ? p.pos.y : 0)
var c = p.color || {r: 1, g: 1, b: 1, a: 1}
// v0: bottom-left
verts.wf(px - hw); verts.wf(py - hh); verts.wf(0); verts.wf(1)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(c.a)
// v1: bottom-right
verts.wf(px + hw); verts.wf(py - hh); verts.wf(1); verts.wf(1)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(c.a)
// v2: top-right
verts.wf(px + hw); verts.wf(py + hh); verts.wf(1); verts.wf(0)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(c.a)
// v3: top-left
verts.wf(px - hw); verts.wf(py + hh); verts.wf(0); verts.wf(0)
verts.wf(c.r); verts.wf(c.g); verts.wf(c.b); verts.wf(c.a)
count++
}
this.geom_cache.verts = stone(verts)
this.geom_cache.active_count = count
}
}
// Scene graph manager
var SceneGraph = {
root: null,
all_nodes: null,
dirty_nodes: null,
_id_counter: 0,
stats: {
total_nodes: 0,
dirty_this_frame: 0,
geometry_rebuilds: 0
},
_gen_id: function() {
return 'node_' + text(this._id_counter++)
},
create: function(type, props) {
props = props || {}
var node = null
switch (type) {
case 'sprite':
node = meme(SpriteNode)
break
case 'tilemap':
node = meme(TilemapNode)
break
case 'text':
node = meme(TextNode)
break
case 'group':
node = meme(GroupNode)
break
case 'particles':
node = meme(ParticleNode)
break
default:
node = meme(BaseNode)
node.type = type || 'node'
}
// Apply base node properties
node.id = props.id || this._gen_id()
node.dirty = DIRTY.ALL
node.parent = null
node.layer = props.layer || 0
node.pos = props.pos || {x: 0, y: 0}
node.opacity = props.opacity != null ? props.opacity : 1
node.world_pos = {x: 0, y: 0}
node.world_tint = {r: 1, g: 1, b: 1, a: 1}
node.world_opacity = 1
// Apply type-specific properties
for (var k in props) {
if (k != 'id' && k != 'layer' && k != 'pos' && k != 'opacity') {
node[k] = props[k]
}
}
if (!this.all_nodes) this.all_nodes = {}
this.all_nodes[node.id] = node
this.stats.total_nodes++
return node
},
remove: function(node) {
if (!node) return
if (this.all_nodes && this.all_nodes[node.id]) {
delete this.all_nodes[node.id]
this.stats.total_nodes--
}
if (node.parent) {
node.parent.remove_child(node)
}
// Recursively remove children
if (node.children) {
for (var c of node.children) {
this.remove(c)
}
}
},
// Update world transforms and rebuild dirty geometry
update: function(font_system) {
this.stats.dirty_this_frame = 0
this.stats.geometry_rebuilds = 0
this.dirty_nodes = []
if (!this.root) return
// Phase 1: Propagate transforms
this._update_transforms(this.root, {x: 0, y: 0}, {r: 1, g: 1, b: 1, a: 1}, 1)
// Phase 2: Rebuild geometry for dirty nodes
for (var i = 0; i < this.dirty_nodes.length; i++) {
var node = this.dirty_nodes[i]
if (node.rebuild_geometry) {
if (node.type == 'text') {
node.rebuild_geometry(font_system)
} else {
node.rebuild_geometry()
}
this.stats.geometry_rebuilds++
}
node.dirty = DIRTY.NONE
}
},
_update_transforms: function(node, parent_pos, parent_tint, parent_opacity) {
if (!node) return
var pos_changed = (node.dirty & DIRTY.TRANSFORM) != 0
var content_changed = (node.dirty & DIRTY.CONTENT) != 0
// Compute world position
var node_pos = node.pos || {x: 0, y: 0}
node.world_pos = {x: parent_pos.x + node_pos.x, y: parent_pos.y + node_pos.y}
// Compute world tint
var nt = node.color || node.tint || {r: 1, g: 1, b: 1, a: 1}
node.world_tint = {
r: parent_tint.r * nt.r,
g: parent_tint.g * nt.g,
b: parent_tint.b * nt.b,
a: parent_tint.a * nt.a
}
node.world_opacity = parent_opacity * (node.opacity != null ? node.opacity : 1)
// Mark for geometry rebuild if needed
if (pos_changed || content_changed) {
this.dirty_nodes.push(node)
this.stats.dirty_this_frame++
}
// Recurse children
if (node.children) {
for (var i = 0; i < node.children.length; i++) {
this._update_transforms(node.children[i], node.world_pos, node.world_tint, node.world_opacity)
}
}
},
// Find node by id
find: function(id) {
return this.all_nodes ? this.all_nodes[id] : null
},
// Clear entire scene
clear: function() {
this.root = null
this.all_nodes = {}
this.dirty_nodes = []
this._id_counter = 0
this.stats.total_nodes = 0
}
}
// Factory function - returns a new scene graph instance
return function() {
var sg = meme(SceneGraph)
sg.all_nodes = {}
sg.dirty_nodes = []
sg._id_counter = 0
sg.stats = {
total_nodes: 0,
dirty_this_frame: 0,
geometry_rebuilds: 0
}
return sg
}