This commit is contained in:
2025-12-23 00:53:00 -06:00
parent 2d6fa94db1
commit 4a29a49f28
3 changed files with 501 additions and 37 deletions

416
clay2.cm Normal file
View File

@@ -0,0 +1,416 @@
// clay2.cm - Revised UI layout engine emitting scene trees
//
// Changes from clay.cm:
// - No __proto__, uses meme/merge
// - Emits scene tree nodes for fx_graph instead of immediate draw commands
// - Supports scissor clipping on groups
var layout = use('layout')
var graphics = use('graphics')
var prosperon = use('prosperon')
var clay = {}
// Layout context
var lay_ctx = layout.make_context()
// Base configuration for UI elements
var base_config = {
font: null,
background_image: null,
slice: 0,
font_path: 'fonts/dos', // Default font
font_size: 16,
color: {r:1, g:1, b:1, a:1},
spacing: 0,
padding: 0,
margin: 0,
offset: {x:0, y:0},
size: null,
background_color: null,
clipped: false,
text_break: 'word',
text_align: 'left',
max_size: null,
contain: 0,
behave: 0
}
function normalize_spacing(s) {
if (typeof s == 'number') return {l:s, r:s, t:s, b:s}
if (isa(s, array)) {
if (s.length == 2) return {l:s[0], r:s[0], t:s[1], b:s[1]}
if (s.length == 4) return {l:s[0], r:s[1], t:s[2], b:s[3]}
}
if (typeof s == 'object') return {l:s.l||0, r:s.r||0, t:s.t||0, b:s.b||0}
return {l:0, r:0, t:0, b:0}
}
// Tree building state
var root_item
var tree_root
var config_stack = []
clay.layout = function(fn, size) {
lay_ctx.reset()
var root = lay_ctx.item()
if (isa(size, array)) size = {width: size[0], height: size[1]}
lay_ctx.set_size(root, size)
lay_ctx.set_contain(root, layout.contain.row) // Default root layout
root_item = root
var root_config = meme(base_config)
tree_root = {
id: root,
config: root_config,
children: []
}
config_stack = [root_config]
fn()
lay_ctx.run()
// Post-layout: build scene tree
return build_scene_tree(tree_root, size.height)
}
function build_scene_tree(node, root_height, parent_abs_x, parent_abs_y) {
parent_abs_x = parent_abs_x || 0
parent_abs_y = parent_abs_y || 0
var rect = lay_ctx.get_rect(node.id)
// Calculate absolute world Y for this node (bottom-up layout to top-down render)
// rect.y is from bottom
var abs_y = root_height - (rect.y + rect.height)
var abs_x = rect.x
// Calculate relative position for the group
var rel_x = abs_x - parent_abs_x
var rel_y = abs_y - parent_abs_y
// The node to return. It might be a group or a sprite/text depending on config.
var scene_node = {
type: 'group',
pos: {x: rel_x + node.config.offset.x, y: rel_y + node.config.offset.y},
width: rect.width,
height: rect.height,
children: []
}
// Background
if (node.config.background_image) {
if (node.config.slice) {
scene_node.children.push({
type: 'sprite',
image: node.config.background_image,
width: rect.width,
height: rect.height,
slice: node.config.slice,
color: node.config.background_color || {r:1, g:1, b:1, a:1},
layer: -1 // Back
})
} else {
scene_node.children.push({
type: 'sprite',
image: node.config.background_image,
width: rect.width,
height: rect.height,
color: node.config.background_color || {r:1, g:1, b:1, a:1},
layer: -1
})
}
} else if (node.config.background_color) {
scene_node.children.push({
type: 'rect',
width: rect.width,
height: rect.height,
color: node.config.background_color,
layer: -1
})
}
// Content (Image/Text)
if (node.config.image) {
scene_node.children.push({
type: 'sprite',
image: node.config.image,
width: rect.width, // layout ensures aspect ratio if configured
height: rect.height,
color: node.config.color
})
}
if (node.config.text) {
scene_node.children.push({
type: 'text',
text: node.config.text,
font: node.config.font_path,
size: node.config.font_size,
color: node.config.color,
pos: {x: 0, y: rect.height} // Text baseline relative to group
})
}
// Clipping
if (node.config.clipped) {
// Scissor needs absolute coordinates
// We can compute them from our current absolute position
scene_node.scissor = {
x: abs_x + node.config.offset.x,
y: abs_y + node.config.offset.y,
width: rect.width,
height: rect.height
}
}
// Children
// Pass our absolute position as the new parent absolute position
var my_abs_x = abs_x + node.config.offset.x
var my_abs_y = abs_y + node.config.offset.y
for (var child of node.children) {
var child_node = build_scene_tree(child, root_height, my_abs_x, my_abs_y)
scene_node.children.push(child_node)
}
return scene_node
}
// --- Item Creation Helpers ---
function add_item(config) {
var parent_config = config_stack[config_stack.length-1]
// Merge parent spacing/padding/margin logic?
// clay.cm does normalizing and applying child_gap.
var use_config = meme(base_config, config)
var item = lay_ctx.item()
lay_ctx.set_margins(item, normalize_spacing(use_config.margin))
lay_ctx.set_contain(item, use_config.contain)
lay_ctx.set_behave(item, use_config.behave)
if (use_config.size) {
var s = use_config.size
if (isa(s, array)) s = {width: s[0], height: s[1]}
lay_ctx.set_size(item, s)
}
var node = {
id: item,
config: use_config,
children: []
}
// Link to tree
// We need to know current parent node.
// Track `tree_stack` alongside `config_stack`?
// Or just `tree_node_stack`.
// Let's fix the state tracking.
}
// Rewriting state management for cleaner recursion
var tree_stack = []
clay.layout = function(fn, size) {
lay_ctx.reset()
var root_id = lay_ctx.item()
if (isa(size, array)) size = {width: size[0], height: size[1]}
lay_ctx.set_size(root_id, size)
lay_ctx.set_contain(root_id, layout.contain.row)
var root_node = {
id: root_id,
config: meme(base_config, {size: size}),
children: []
}
tree_stack = [root_node]
fn() // User builds tree
lay_ctx.run()
return build_scene_tree(root_node, size.height)
}
function process_configs(configs) {
// Merge array of configs from right to left (right overrides left)
// And merge with base_config
var res = meme(base_config)
for (var c of configs) {
if (c) res = meme(res, c)
}
return res
}
function push_node(configs, contain_mode) {
var config = process_configs(configs)
if (contain_mode != null) config.contain = contain_mode
var item = lay_ctx.item()
// Apply layout props
lay_ctx.set_margins(item, normalize_spacing(config.margin))
lay_ctx.set_contain(item, config.contain)
lay_ctx.set_behave(item, config.behave)
if (config.size) {
var s = config.size
if (isa(s, array)) s = {width: s[0], height: s[1]}
lay_ctx.set_size(item, s)
}
var node = {
id: item,
config: config,
children: []
}
// Add to parent
var parent = tree_stack[tree_stack.length-1]
parent.children.push(node)
lay_ctx.insert(parent.id, item)
tree_stack.push(node)
return node
}
function pop_node() {
tree_stack.pop()
}
// Generic container
clay.container = function(configs, fn) {
if (typeof configs == 'function') { fn = configs; configs = {} }
if (!isa(configs, array)) configs = [configs]
push_node(configs, null)
if (fn) fn()
pop_node()
}
// Stacks
clay.vstack = function(configs, fn) {
if (typeof configs == 'function') { fn = configs; configs = {} }
if (!isa(configs, array)) configs = [configs]
var c = layout.contain.column
// Check for alignment/justification in configs?
// Assume generic container props handle it via `contain` override or we defaults
push_node(configs, c)
if (fn) fn()
pop_node()
}
clay.hstack = function(configs, fn) {
if (typeof configs == 'function') { fn = configs; configs = {} }
if (!isa(configs, array)) configs = [configs]
var c = layout.contain.row
push_node(configs, c)
if (fn) fn()
pop_node()
}
clay.zstack = function(configs, fn) {
if (typeof configs == 'function') { fn = configs; configs = {} }
if (!isa(configs, array)) configs = [configs]
// Stack (overlap)
// layout.contain.stack? layout.contain.overlap?
// 'layout' module usually defaults to overlap if not row/column?
// Or we just don't set row/column bit.
var c = layout.contain.layout // Just layout (no flow)
push_node(configs, c)
if (fn) fn()
pop_node()
}
// Leaf nodes
clay.image = function(path, ...configs) {
var img = graphics.texture(path)
var c = {image: path}
// Auto-size if not provided
// But we need to check if configs override it
var final_config = process_configs(configs)
if (!final_config.size && !final_config.behave) { // If no size and no fill behavior
c.size = {width: img.width, height: img.height}
}
push_node([c, ...configs], null)
pop_node()
}
clay.text = function(str, ...configs) {
var c = {text: str}
// Measuring
var final_config = process_configs(configs)
// measure text
var font = graphics.get_font(final_config.font_path) // or font cache
// Need to handle font object vs path string
// For now assume path string in config or default
// This measurement is synchronous and might differ from GPU font rendering slightly
// but good enough for layout.
// We need to know max width for wrapping?
// 'layout' doesn't easily support "height depends on width" during single pass?
// We specify a fixed size or behave.
// If no size specified, measure single line
if (!final_config.size && !final_config.behave) {
// Basic measurement
// Hack: use arbitrary width for now?
// Or we need a proper text measurement exposed.
// graphics.measure_text(font, text, size, break, align)
// Assume we have it or minimal version.
// clay.cm used `font.text_size`
// We'll rely on font path to get a font object
// var f = graphics.get_font(final_config.font_path) ...
// c.size = ...
c.size = {width: 100, height: 20} // Fallback for now to avoid crashes
}
push_node([c, ...configs], null)
pop_node()
}
clay.rectangle = function(...configs) {
// Just a container with background color really, but as a leaf
push_node(configs, null)
pop_node()
}
clay.button = function(str, action, ...configs) {
// Button is a container with text and click behavior
// For rendering, it's a zstack of background + text
// We can just define it as a container with background styling
var btn_config = {
padding: 10,
background_color: {r:0.3, g:0.3, b:0.4, a:1}
}
clay.zstack([btn_config, ...configs], function() {
clay.text(str, {color: {r:1,g:1,b:1,a:1}})
})
}
// Constants
clay.behave = layout.behave
clay.contain = layout.contain
return clay

View File

@@ -172,7 +172,15 @@ NODE_EXECUTORS.render_view = function(params, backend) {
// Batch and emit draw commands
var batches = batch_drawables(drawables)
var current_scissor = null
for (var batch of batches) {
// Emit scissor command if changed
if (!rect_equal(current_scissor, batch.scissor)) {
commands.push({cmd: 'scissor', rect: batch.scissor})
current_scissor = batch.scissor
}
if (batch.type == 'sprite_batch') {
commands.push({
cmd: 'draw_batch',
@@ -393,7 +401,7 @@ NODE_EXECUTORS.shader_pass = function(params, backend) {
// SCENE TREE TRAVERSAL
// ========================================================================
function collect_drawables(node, camera, parent_tint, parent_opacity) {
function collect_drawables(node, camera, parent_tint, parent_opacity, parent_scissor, parent_pos) {
if (!node) return []
parent_tint = parent_tint || [1, 1, 1, 1]
@@ -401,6 +409,14 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
var drawables = []
// Compute absolute position
parent_pos = parent_pos || {x: 0, y: 0}
var node_pos = node.pos || {x: 0, y: 0}
var abs_x = parent_pos.x + (node_pos.x != null ? node_pos.x : (node_pos[0] || 0))
var abs_y = parent_pos.y + (node_pos.y != null ? node_pos.y : (node_pos[1] || 0))
// For recursive calls, use this node's absolute pos as parent pos
var current_pos = {x: abs_x, y: abs_y}
// Compute inherited tint/opacity
var node_tint = node.tint || node.color
var world_tint = [
@@ -411,16 +427,29 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
]
var world_opacity = parent_opacity * (node.opacity != null ? node.opacity : 1)
// Handle different node types
// Compute effective scissor
var current_scissor = parent_scissor
if (node.scissor) {
if (parent_scissor) {
// Intersect parent and node scissor
var x1 = Math.max(parent_scissor.x, node.scissor.x)
var y1 = Math.max(parent_scissor.y, node.scissor.y)
var x2 = Math.min(parent_scissor.x + parent_scissor.width, node.scissor.x + node.scissor.width)
var y2 = Math.min(parent_scissor.y + parent_scissor.height, node.scissor.y + node.scissor.height)
current_scissor = {x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1)}
} else {
current_scissor = node.scissor
}
}
// Handle different node types
if (node.type == 'sprite' || (node.image && !node.type)) {
if (node.slice && node.tile) {
throw Error('Sprite cannot have both "slice" and "tile" parameters.')
}
var pos = node.pos || {x: 0, y: 0}
var px = pos.x != null ? pos.x : (pos[0] || 0)
var py = pos.y != null ? pos.y : (pos[1] || 0)
var px = abs_x
var py = abs_y
var w = node.width || 1
var h = node.height || 1
var ax = node.anchor_x || 0
@@ -442,7 +471,8 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
anchor_y: 0,
uv_rect: uv,
color: tint,
material: node.material
material: node.material,
scissor: current_scissor
})
}
@@ -536,7 +566,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
type: 'sprite',
layer: node.layer || 0,
world_y: py,
pos: pos,
pos: {x: abs_x, y: abs_y},
image: node.image,
texture: node.texture,
width: w,
@@ -550,40 +580,44 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
}
if (node.type == 'text') {
var pos = node.pos || {x: 0, y: 0}
drawables.push({
type: 'text',
layer: node.layer || 0,
world_y: pos.y != null ? pos.y : (pos[1] || 0),
pos: pos,
world_y: abs_y,
pos: {x: abs_x, y: abs_y},
text: node.text,
font: node.font,
size: node.size,
color: tint_to_color(world_tint, world_opacity)
color: tint_to_color(world_tint, world_opacity),
scissor: current_scissor
})
}
if (node.type == 'rect') {
var pos = node.pos || {x: 0, y: 0}
drawables.push({
type: 'rect',
layer: node.layer || 0,
world_y: pos.y != null ? pos.y : (pos[1] || 0),
pos: pos,
world_y: abs_y,
pos: {x: abs_x, y: abs_y},
width: node.width || 1,
height: node.height || 1,
color: tint_to_color(world_tint, world_opacity)
color: tint_to_color(world_tint, world_opacity),
scissor: current_scissor
})
}
if (node.type == 'particles' || node.particles) {
var particles = node.particles || []
for (var p of particles) {
// Particles usually relative to emitter (node) pos
var px = p.pos ? p.pos.x : 0
var py = p.pos ? p.pos.y : 0
drawables.push({
type: 'sprite',
layer: node.layer || 0,
world_y: p.pos ? p.pos.y : 0,
pos: p.pos || {x: 0, y: 0},
world_y: abs_y + py, // Sort by Y
pos: {x: abs_x + px, y: abs_y + py}, // Add parent/node pos to particle pos
image: node.image,
texture: node.texture,
width: (node.width || 1) * (p.scale || 1),
@@ -591,7 +625,8 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
anchor_x: 0.5,
anchor_y: 0.5,
color: p.color || tint_to_color(world_tint, world_opacity),
material: node.material
material: node.material,
scissor: current_scissor
})
}
}
@@ -610,8 +645,10 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
var tile = tiles[x][y]
if (!tile) continue
var world_x = (x + offset_x) * scale_x
var world_y_pos = (y + offset_y) * scale_y
// Tile coords are strictly grid based + offset.
// We should add this node's position (abs_x, abs_y) to it
var world_x = abs_x + (x + offset_x) * scale_x
var world_y_pos = abs_y + (y + offset_y) * scale_y
drawables.push({
type: 'sprite',
@@ -624,8 +661,10 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
height: scale_y,
anchor_x: 0,
anchor_y: 0,
anchor_y: 0,
color: tint_to_color(world_tint, world_opacity),
material: node.material
material: node.material,
scissor: current_scissor
})
}
}
@@ -634,7 +673,7 @@ function collect_drawables(node, camera, parent_tint, parent_opacity) {
// Recurse children
if (node.children) {
for (var child of node.children) {
var child_drawables = collect_drawables(child, camera, world_tint, world_opacity)
var child_drawables = collect_drawables(child, camera, world_tint, world_opacity, current_scissor, current_pos)
drawables = drawables.concat(child_drawables)
}
}
@@ -663,17 +702,20 @@ function batch_drawables(drawables) {
if (drawable.type == 'sprite') {
var texture = drawable.texture || drawable.image
var material = drawable.material || {blend: 'alpha', sampler: 'nearest'}
// Start new batch if texture/material changed
var scissor = drawable.scissor
// Start new batch if texture/material/scissor changed
if (!current_batch ||
current_batch.type != 'sprite_batch' ||
current_batch.texture != texture ||
!rect_equal(current_batch.scissor, scissor) ||
!materials_equal(current_batch.material, material)) {
if (current_batch) batches.push(current_batch)
current_batch = {
type: 'sprite_batch',
texture: texture,
material: material,
scissor: scissor,
sprites: []
}
}
@@ -685,7 +727,7 @@ function batch_drawables(drawables) {
batches.push(current_batch)
current_batch = null
}
batches.push({type: drawable.type, drawable: drawable})
batches.push({type: drawable.type, drawable: drawable, scissor: drawable.scissor})
}
}
@@ -694,6 +736,12 @@ function batch_drawables(drawables) {
return batches
}
function rect_equal(a, b) {
if (!a && !b) return true
if (!a || !b) return false
return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height
}
function materials_equal(a, b) {
if (!a || !b) return a == b
return a.blend == b.blend && a.sampler == b.sampler && a.shader == b.shader

View File

@@ -668,7 +668,7 @@ function _build_sprite_vertices(sprites, camera) {
vertex_data.wf(x)
vertex_data.wf(y)
vertex_data.wf(u0)
vertex_data.wf(v0)
vertex_data.wf(v1) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
@@ -678,7 +678,7 @@ function _build_sprite_vertices(sprites, camera) {
vertex_data.wf(x + w)
vertex_data.wf(y)
vertex_data.wf(u1)
vertex_data.wf(v0)
vertex_data.wf(v1) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
@@ -688,7 +688,7 @@ function _build_sprite_vertices(sprites, camera) {
vertex_data.wf(x + w)
vertex_data.wf(y + h)
vertex_data.wf(u1)
vertex_data.wf(v1)
vertex_data.wf(v0) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
@@ -698,7 +698,7 @@ function _build_sprite_vertices(sprites, camera) {
vertex_data.wf(x)
vertex_data.wf(y + h)
vertex_data.wf(u0)
vertex_data.wf(v1)
vertex_data.wf(v0) // Flip V
vertex_data.wf(c.r)
vertex_data.wf(c.g)
vertex_data.wf(c.b)
@@ -1364,8 +1364,8 @@ function _do_mask(cmd_buffer, cmd) {
uniform_data.wf(0) // padding
uniform_data.wf(0) // padding
// Render pass
var pass = cmd_buffer.render_pass({
// Render to output
var mask_pass = cmd_buffer.render_pass({
color_targets: [{
texture: output.texture,
load: "clear",
@@ -1374,17 +1374,17 @@ function _do_mask(cmd_buffer, cmd) {
}]
})
pass.bind_pipeline(_pipelines.mask)
pass.bind_vertex_buffers(0, [{buffer: vb, offset: 0}])
pass.bind_index_buffer({buffer: ib, offset: 0}, 16)
mask_pass.bind_pipeline(_pipelines.mask)
mask_pass.bind_vertex_buffers(0, [{buffer: vb, offset: 0}])
mask_pass.bind_index_buffer({buffer: ib, offset: 0}, 16)
// Bind both content texture (slot 0) and mask texture (slot 1)
pass.bind_fragment_samplers(0, [
mask_pass.bind_fragment_samplers(0, [
{texture: content.texture, sampler: _sampler_nearest},
{texture: mask.texture, sampler: _sampler_nearest}
])
cmd_buffer.push_fragment_uniform_data(0, stone(uniform_data))
pass.draw_indexed(6, 1, 0, 0, 0)
pass.end()
mask_pass.draw_indexed(6, 1, 0, 0, 0)
mask_pass.end()
}
function _do_shader_pass(cmd_buffer, cmd) {