This commit is contained in:
2026-01-08 09:10:37 -06:00
parent 4ea9d43a94
commit 9ee3428578
8 changed files with 30 additions and 738 deletions

View File

@@ -37,6 +37,17 @@ var base_config = {
behave: 0
}
function normalize_color(c, fallback) {
fallback = fallback || {r:1, g:1, b:1, a:1}
if (!c) return {r:fallback.r, g:fallback.g, b:fallback.b, a:fallback.a}
return {
r: c.r != null ? c.r : fallback.r,
g: c.g != null ? c.g : fallback.g,
b: c.b != null ? c.b : fallback.b,
a: c.a != null ? c.a : fallback.a
}
}
function normalize_spacing(s) {
if (is_number(s)) return {l:s, r:s, t:s, b:s}
if (is_array(s)) {
@@ -58,7 +69,6 @@ var tree_stack = []
clay.layout = function(fn, size) {
lay_ctx.reset()
var root_id = lay_ctx.item()
if (is_array(size)) size = {width: size[0], height: size[1]}
lay_ctx.set_size(root_id, size)
lay_ctx.set_contain(root_id, layout.contain.row)
@@ -90,19 +100,6 @@ function build_drawables(node, root_height, parent_abs_x, parent_abs_y, parent_s
var abs_y = root_height - (rect.y + rect.height)
var abs_x = rect.x
// Our absolute position including parent offsets logic from original clay?
// Actually layout engine gives rects relative to root usually?
// No, layout engine gives RELATIVE logic but `get_rect` usually returns computed layout relative to parent or absolute?
// Let's assume `get_rect` returns relative to parent.
// Wait, `clay.cm` assumed `rect.x` was absolute?
// "Calculate relative position for the group: rel_x = abs_x - parent_abs_x".
// This implies `rect.x` is absolute.
// Let's verify standard behavior. If `layout` returns absolute coords, we don't need to accumulate parent_abs_x for position,
// BUT we do need it if `clay` was doing local group offsets.
// Original `clay2.cm`: `var rel_x = abs_x - parent_abs_x`. This implies `rect` is absolute.
// So `abs_x` IS `rect.x`.
// IMPORTANT: The offset in config is applied VISUALLY.
var vis_x = abs_x + node.config.offset.x
var vis_y = abs_y + node.config.offset.y
@@ -219,7 +216,15 @@ function build_drawables(node, root_height, parent_abs_x, parent_abs_y, parent_s
// --- Item Creation Helpers ---
function process_configs(configs) {
return meme(base_config, ...configs)
var cfg = meme(base_config, ...configs)
cfg.color = normalize_color(cfg.color, base_config.color)
if (cfg.background_color) cfg.background_color = normalize_color(cfg.background_color, {r:1,g:1,b:1,a:1})
if (!cfg.offset) cfg.offset = {x:0, y:0}
else cfg.offset = {x: cfg.offset.x || 0, y: cfg.offset.y || 0}
return cfg
}
function push_node(configs, contain_mode) {

View File

@@ -3,9 +3,9 @@ function grid(w, h) {
this.height = h;
// create a height×width array of empty lists
this.cells = new Array(h);
for (let y = 0; y < h; y++) {
for (var y = 0; y < h; y++) {
this.cells[y] = new Array(w);
for (let x = 0; x < w; x++) {
for (var x = 0; x < w; x++) {
this.cells[y][x] = []; // each cell holds its own list
}
}
@@ -45,10 +45,10 @@ grid.prototype = {
// call fn(entity, coord) for every entity in every cell
each(fn) {
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
for (var y = 0; y < this.height; y++) {
for (var x = 0; x < this.width; x++) {
def list = this.cells[y][x];
for (let entity of list) {
for (var entity of list) {
fn(entity, entity.coord);
}
}
@@ -57,9 +57,9 @@ grid.prototype = {
// printable representation
toString() {
let out = `grid [${this.width}×${this.height}]\n`;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
var out = `grid [${this.width}×${this.height}]\n`;
for (var y = 0; y < this.height; y++) {
for (var x = 0; x < this.width; x++) {
out += this.cells[y][x].length;
}
if (y != this.height - 1) out += "\n";

View File

@@ -1,5 +0,0 @@
// particles
return function() {
}

View File

@@ -432,60 +432,3 @@ PlaydateBackend.prototype.cpu_box_blur = function(src, dst, radius) {
dst.setData(dst_data)
}
// ========================================================================
// KEY DIFFERENCES FROM SDL3 BACKEND
// ========================================================================
// 1. NO SHADERS
// - shader_pass() degrades gracefully (copy, or CPU fallback)
// - Complex effects (bloom, CRT) either disabled or approximated
// 2. NATIVE MASKING
// - apply_mask() is BETTER on Playdate (uses setMask() API)
// - SDL3 needs shader or stencil buffer
// - Playdate has it built-in
// 3. 1-BIT DISPLAY
// - Colors become dither patterns
// - Alpha becomes dither density
// - Blend modes approximate (XOR for additive)
// 4. CPU RENDERING
// - Some effects run on CPU (blur)
// - Slower but possible
// - Can skip effects if too slow (check frame time)
// 5. NO COMMAND BUFFER
// - Commands execute immediately
// - No batching at GPU level
// - But batching still happens at Level 2 (same as SDL3)
// 6. LIMITED SCALING
// - Only nearest-neighbor
// - No bilinear filter
// - Fine for pixel art!
// ========================================================================
// WHAT STAYS THE SAME
// ========================================================================
// - Scene tree traversal (Level 2)
// - Drawable collection (Level 2)
// - Sorting (Level 2)
// - Batching (Level 2)
// - Node graph structure (Level 2)
// - High-level API (Level 1)
// Only THIS FILE changes between SDL3 and Playdate.
// Game code is 100% identical.
// Example: Bullets with emissive lighting
//
// SDL3: Bullets → bloom shader → additive composite (GPU)
// Playdate: Bullets → XOR composite (approximation)
//
// Same high-level code:
// compositor.add_emissive_lighting({tag: 'bullets'})
//
// Different results, but both correct for their platform.
// That's the point of the abstraction!

View File

@@ -480,7 +480,6 @@ function pack_model_buffer(material) {
return stone(result_blob);
}
var imgui = use('imgui')
imgui.init(window, device)
var rasterize = use('rasterize');

650
scene.cm
View File

@@ -1,650 +0,0 @@
// 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
}

View File

@@ -73,7 +73,7 @@ var fpsTimer=0, fpsCount=0
Anim.minDelay = 1 / 100; // 10 ms, feel free to tune later
let last = os.now();
var last = os.now();
function loop(){
def now = os.now();

View File

@@ -6,7 +6,7 @@ var json = use('json');
var cameras = camera.list();
if (cameras.length == 0) {
log.console("No cameras found!");
$ stop();
$stop();
}
var cam_id = cameras[0];