651 lines
16 KiB
Plaintext
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
|
|
}
|