From 0ab4a47ebcfea2085435fb3037595c90c6ed973c Mon Sep 17 00:00:00 2001 From: adrien Date: Wed, 8 Apr 2026 23:27:01 +0200 Subject: [PATCH] Basic multi node type --- scripts/box.lua | 34 --------- scripts/enemy_rect.lua | 46 ++++++++++++ scripts/engine.lua | 114 ++++++++++++++++++++++++++++ scripts/healthbar.lua | 39 ++++++++++ scripts/player.lua | 57 ++++++-------- src/Engine.zig | 20 ++++- src/Node.zig | 167 +++++++++++++++++++++++++++++++++-------- src/main.zig | 15 +++- 8 files changed, 393 insertions(+), 99 deletions(-) delete mode 100644 scripts/box.lua create mode 100644 scripts/enemy_rect.lua create mode 100644 scripts/engine.lua create mode 100644 scripts/healthbar.lua diff --git a/scripts/box.lua b/scripts/box.lua deleted file mode 100644 index 8b4f213..0000000 --- a/scripts/box.lua +++ /dev/null @@ -1,34 +0,0 @@ --- scripts/box.lua --- Autonomous bouncing box. --- Edit vx, vy, or the color below, save the file, and watch it update live! - -local vx, vy = 2050, 110 -- ← try cranking these up -local size = 60 -local r, g, b = 255, 180, 50 -- ← golden amber box - -function on_init() - log("Box ready – bouncing at vx=" .. vx .. " vy=" .. vy) -end - -function on_update(dt) - self_x = self_x + vx * dt - self_y = self_y + vy * dt - - -- Bounce off the four walls (respecting the editor panel) - if self_x < 0 or self_x + size > 1055 then - vx = -vx - self_x = math.max(0, math.min(1055 - size, self_x)) - end - if self_y < 0 or self_y + size > 720 then - vy = -vy - self_y = math.max(0, math.min(720 - size, self_y)) - end -end - -function on_render() - draw_rect(self_x, self_y, size, size, r, g, b) - - -- Velocity debug overlay - local speed_label = "v=" .. math.floor(math.sqrt(vx * vx + vy * vy)) - draw_text(speed_label, self_x + 4, self_y + size / 2 - 6, 12, 0, 0, 0) -end diff --git a/scripts/enemy_rect.lua b/scripts/enemy_rect.lua new file mode 100644 index 0000000..a8ae22e --- /dev/null +++ b/scripts/enemy_rect.lua @@ -0,0 +1,46 @@ +-- scripts/enemy_rect.lua +-- RectNode: bounces around, changes color on bounce. +-- Try editing vx/vy or the color while the engine is running! + +local vx, vy = 180, 130 + +---@param self RectNode +function on_init(self) + self.x = 600 + self.y = 300 + -- self.color already set by engine (passed in addRectNode), + -- but we can override it here. + self.color = { r = 220, g = 60, b = 60, a = 255 } +end + +---@param self RectNode +---@param dt number +function on_update(self, dt) + self.x = self.x + vx * dt + self.y = self.y + vy * dt + + if self.x < 0 or self.x + self.width > 1030 then + vx = -vx + self.x = math.max(0, math.min(1030 - self.width, self.x)) + -- Flash yellow on bounce + self.color = { r = 240, g = 220, b = 50, a = 255 } + end + if self.y < 0 or self.y + self.height > 720 then + vy = -vy + self.y = math.max(0, math.min(720 - self.height, self.y)) + self.color = { r = 240, g = 220, b = 50, a = 255 } + end + + -- Fade back to red + self.color.r = math.min(220, self.color.r + 180 * dt) + self.color.g = math.max(60, self.color.g - 300 * dt) + self.color.b = math.max(60, self.color.b - 200 * dt) +end + +---@param self RectNode +function on_render(self) + -- Label over auto-drawn rect + draw_text("Enemy", self.x + 4, self.y + 18, 13, 255, 210, 210) + local spd = math.floor(math.sqrt(vx * vx + vy * vy)) + draw_text("v=" .. spd, self.x + 4, self.y + 34, 10, 200, 200, 200) +end diff --git a/scripts/engine.lua b/scripts/engine.lua new file mode 100644 index 0000000..a5a9de0 --- /dev/null +++ b/scripts/engine.lua @@ -0,0 +1,114 @@ +---@meta engine +--- +--- LSP type stubs for the Zig engine API. +--- This file is NEVER executed — it only exists so lua-language-server +--- can provide autocomplete, type checking, and inline docs. +--- +--- Usage in your scripts: +--- ---@param self RectNode (or NodeBase for base nodes) + +-- ─── Color type ────────────────────────────────────────────────────────────── + +---@class Color +---@field r number Red 0-255 +---@field g number Green 0-255 +---@field b number Blue 0-255 +---@field a number Alpha 0-255 + +-- ─── Node self types ───────────────────────────────────────────────────────── + +---@class NodeBase +---@field x number World X position (read/write) +---@field y number World Y position (read/write) + +--- Extends NodeBase with rectangle geometry. +--- The engine draws the rect each frame; modify fields to change it. +---@class RectNode : NodeBase +---@field width number Rectangle width +---@field height number Rectangle height +---@field color Color Fill color (r/g/b/a subtable) + +-- ─── Drawing ───────────────────────────────────────────────────────────────── + +---@param x number +---@param y number +---@param w number +---@param h number +---@param r number Red 0-255 +---@param g number Green 0-255 +---@param b number Blue 0-255 +---@param a? number Alpha 0-255 (default 255) +function draw_rect(x, y, w, h, r, g, b, a) end + +---@param x number +---@param y number +---@param radius number +---@param r number +---@param g number +---@param b number +---@param a? number +function draw_circle(x, y, radius, r, g, b, a) end + +---@param text string +---@param x number +---@param y number +---@param size number Font size in pixels +---@param r number +---@param g number +---@param b number +---@param a? number +function draw_text(text, x, y, size, r, g, b, a) end + +---@param x1 number +---@param y1 number +---@param x2 number +---@param y2 number +---@param r number +---@param g number +---@param b number +---@param a? number +function draw_line(x1, y1, x2, y2, r, g, b, a) end + +-- ─── Input ─────────────────────────────────────────────────────────────────── + +--- Returns true while key is held. +---@param key integer Use Key.X constants +---@return boolean +function key_down(key) end + +--- Returns true only on the first frame the key is pressed. +---@param key integer +---@return boolean +function key_pressed(key) end + +--- Returns true while mouse button is held. 0=left 1=right 2=middle +---@param button integer +---@return boolean +function mouse_down(button) end + +---@return number +function mouse_x() end + +---@return number +function mouse_y() end + +-- ─── Key constants ─────────────────────────────────────────────────────────── + +---@class KeyTable +---@field LEFT integer +---@field RIGHT integer +---@field UP integer +---@field DOWN integer +---@field SPACE integer +---@field W integer +---@field A integer +---@field S integer +---@field D integer + +---@type KeyTable +Key = {} + +-- ─── Utility ───────────────────────────────────────────────────────────────── + +---@param message string +function log(message) end diff --git a/scripts/healthbar.lua b/scripts/healthbar.lua new file mode 100644 index 0000000..fa1db7b --- /dev/null +++ b/scripts/healthbar.lua @@ -0,0 +1,39 @@ +-- scripts/healthbar.lua +-- RectNode: the engine draws the green bar automatically. +-- This script drives the HP logic and recolors/resizes the bar. + +local max_hp = 100 +local hp = 100 + +---@param self RectNode +function on_init(self) + self.x = 20 + self.y = 20 + self.width = 200 + self.height = 20 + self.color = { r = 60, g = 200, b = 80, a = 255 } + log("HealthBar ready") +end + +---@param self RectNode +---@param dt number +function on_update(self, dt) + -- Slow drain (replace with real damage events) + hp = math.max(0, hp - 4 * dt) + + -- Shrink bar proportionally + self.width = (hp / max_hp) * 200 + + -- Green → red as HP drops + local t = hp / max_hp + self.color.r = math.floor((1 - t) * 220) + self.color.g = math.floor(t * 200) + self.color.b = 40 +end + +---@param self RectNode +function on_render(self) + -- Label drawn on top of the auto-rendered bar + local label = "HP: " .. (math.floor(hp)) .. (" / ") .. max_hp + draw_text(label, self.x + 4, self.y + 4, 12, 240, 240, 240) +end diff --git a/scripts/player.lua b/scripts/player.lua index 48add13..e2c46b2 100644 --- a/scripts/player.lua +++ b/scripts/player.lua @@ -1,42 +1,33 @@ -- scripts/player.lua --- Globals provided each frame by the engine: --- self_x, self_y ← read AND write to move the node --- API: draw_rect draw_circle draw_text draw_line --- key_down key_pressed mouse_down mouse_x mouse_y --- log(msg) +-- Base node: the script handles all drawing. -local speed = 200 -local width = 40 -local height = 50 -local cr, cg, cb = 80, 160, 255 -- ← try changing this color and saving! +local speed = 200 +local w, h = 40, 50 -function on_init() - log("Player init at " .. self_x .. ", " .. self_y) - -- self_x/self_y already hold the last known position, - -- so hot-reload doesn't teleport the player back to origin. +---@param self NodeBase +function on_init(self) + log("Player spawned at " .. (self.x) .. ", " .. (self.y)) end -function on_update(dt) - if key_down(Key.RIGHT) or key_down(Key.D) then self_x = self_x + speed * dt end - if key_down(Key.LEFT) or key_down(Key.A) then self_x = self_x - speed * dt end - if key_down(Key.DOWN) or key_down(Key.S) then self_y = self_y + speed * dt end - if key_down(Key.UP) or key_down(Key.W) then self_y = self_y - speed * dt end +---@param self NodeBase +---@param dt number +function on_update(self, dt) + if key_down(Key.RIGHT) or key_down(Key.D) then self.x = self.x + speed * dt end + if key_down(Key.LEFT) or key_down(Key.A) then self.x = self.x - speed * dt end + if key_down(Key.DOWN) or key_down(Key.S) then self.y = self.y + speed * dt end + if key_down(Key.UP) or key_down(Key.W) then self.y = self.y - speed * dt end - -- Clamp to game area (leave the editor panel free) - self_x = math.max(0, math.min(1055, self_x)) - self_y = math.max(0, math.min(665, self_y)) + self.x = math.max(0, math.min(1030, self.x)) + self.y = math.max(0, math.min(665, self.y)) end -function on_render() - -- Body - draw_rect(self_x, self_y, width, height, cr, cg, cb) - - -- Eyes (white + pupil) - draw_circle(self_x + 11, self_y + 14, 6, 255, 255, 255) - draw_circle(self_x + 29, self_y + 14, 6, 255, 255, 255) - draw_circle(self_x + 13, self_y + 14, 3, 0, 0, 0) - draw_circle(self_x + 31, self_y + 14, 3, 0, 0, 0) - - -- Label - draw_text("Player", self_x, self_y - 16, 11, 180, 220, 255) +---@param self NodeBase +function on_render(self) + draw_rect(self.x, self.y, w, h, 80, 160, 255) + -- eyes + draw_circle(self.x + 11, self.y + 14, 6, 255, 255, 255) + draw_circle(self.x + 29, self.y + 14, 6, 255, 255, 255) + draw_circle(self.x + 13, self.y + 14, 3, 0, 0, 0) + draw_circle(self.x + 31, self.y + 14, 3, 0, 0, 0) + draw_text("Player", self.x, self.y - 16, 11, 180, 220, 255) end diff --git a/src/Engine.zig b/src/Engine.zig index f3145a2..56818b6 100644 --- a/src/Engine.zig +++ b/src/Engine.zig @@ -19,10 +19,28 @@ pub fn deinit(self: *Engine) void { } pub fn addNode(self: *Engine, name: []const u8, script: []const u8) !void { - const node = try Node.init(self.allocator, name, script); + const node = try Node.init(self.allocator, name, .base, script); try self.nodes.append(self.allocator, node); } +/// A rectangle node. Engine auto-draws the rect from Zig properties. +/// Script receives a RectNode self and can change width/height/color. +pub fn addRectNode( + self: *Engine, + name: []const u8, + width: f32, + height: f32, + color: rl.Color, + script: []const u8, +) !void { + const kind: Node.NodeKind = .{ .rectangle = .{ + .width = width, + .height = height, + .color = color, + } }; + try self.nodes.append(self.allocator, try Node.init(self.allocator, name, kind, script)); +} + /// Poll every node's file watcher; reload changed scripts. pub fn checkReloads(self: *Engine) !void { for (self.nodes.items) |*node| { diff --git a/src/Node.zig b/src/Node.zig index c813bfe..89d8cfa 100644 --- a/src/Node.zig +++ b/src/Node.zig @@ -1,9 +1,22 @@ const std = @import("std"); +const rl = @import("raylib"); const zlua = @import("zlua"); const Lua = zlua.Lua; const lua_api = @import("lua_api.zig"); const FileWatcher = @import("FileWatcher.zig"); +pub const RectData = struct { + width: f32 = 50, + height: f32 = 50, + color: rl.Color = rl.Color.white, +}; + +pub const NodeKind = union(enum) { + base: void, + rectangle: RectData, + // extend here: sprite, audio_emitter, tilemap, ... +}; + /// A scene node with an attached Lua script. /// Each node owns a dedicated Lua state → scripts are fully isolated. const Node = @This(); @@ -13,27 +26,33 @@ name: []const u8, script_path: []const u8, lua: *Lua, watcher: FileWatcher, +/// Lua registry key for the persistent self table. +/// Survives hot-reloads so script-side state is preserved. +self_ref: i32 = 0, -// Node transform — synced to/from Lua as self_x, self_y globals -x: f64 = 400, -y: f64 = 300, +x: f32 = 400, +y: f32 = 300, +kind: NodeKind = .base, pub fn init( allocator: std.mem.Allocator, name: []const u8, + kind: NodeKind, script_path: []const u8, ) !Node { const lua = try Lua.init(allocator); lua.openLibs(); - lua_api.registerAll(lua); // expose engine API to this state + lua_api.registerAll(lua); var node = Node{ .allocator = allocator, .name = name, + .kind = kind, .script_path = script_path, .lua = lua, .watcher = FileWatcher.init(script_path), }; + try node.createSelfTable(); try node.loadScript(); return node; } @@ -42,26 +61,25 @@ pub fn deinit(self: *Node) void { self.lua.deinit(); } -/// Load (or hot-reload) the Lua script from disk. +/// Load or hot-reload the Lua script from disk. +/// The self table (and any data stored in it) survives the reload. pub fn loadScript(self: *Node) !void { std.log.info("(re)loading {s}", .{self.script_path}); - const path_z = try self.allocator.dupeZ(u8, self.script_path); defer self.allocator.free(path_z); self.lua.doFile(path_z) catch |err| { const msg = self.lua.toString(-1) catch "?"; - std.log.err("[{s}] load error: {s} ({})", .{ self.script_path, msg, err }); + std.log.err("[{s}] {s} ({})", .{ self.script_path, msg, err }); self.lua.pop(1); - return; // keep running with old code rather than crashing + return; // keep old functions running — don't crash on syntax error }; - self.syncToLua(); // give on_init() the current position - self.callVoid("on_init"); + self.syncToLua(); + self.callWithSelf("on_init"); self.syncFromLua(); } -/// Called every frame. Passes dt as an argument. pub fn update(self: *Node, dt: f32) void { self.syncToLua(); const t = self.lua.getGlobal("on_update") catch return; @@ -69,46 +87,99 @@ pub fn update(self: *Node, dt: f32) void { self.lua.pop(1); return; } + _ = self.lua.rawGetIndex(zlua.registry_index, self.self_ref); self.lua.pushNumber(@floatCast(dt)); - self.lua.protectedCall(.{ .args = 1 }) catch |err| self.logErr("on_update", err); + self.lua.protectedCall(.{ .args = 2 }) catch |err| self.logErr("on_update", err); self.syncFromLua(); } -/// Called every frame after update. pub fn render(self: *Node) void { + // 1. Automatic draw from Zig-side properties + switch (self.kind) { + .rectangle => |r| { + rl.drawRectangle( + @intFromFloat(self.x), + @intFromFloat(self.y), + @intFromFloat(r.width), + @intFromFloat(r.height), + r.color, + ); + }, + .base => {}, + } + // 2. Script overlay (labels, effects, custom shapes, etc.) self.syncToLua(); - self.callVoid("on_render"); + self.callWithSelf("on_render"); } -// ── internals ────────────────────────────────────────────────────────── +// ── Self table ──────────────────────────────────────────────────────────── -/// Write node state into Lua globals so scripts can read/modify them. +fn createSelfTable(self: *Node) !void { + self.lua.newTable(); + // luaL_ref pops the table and returns an integer key in the registry. + // Note: ziglua may return !i32 or !i64 depending on version. + self.self_ref = try self.lua.ref(zlua.registry_index); +} + +/// Write Zig state → Lua self table before any callback. fn syncToLua(self: *Node) void { - self.lua.pushNumber(self.x); - self.lua.setGlobal("self_x"); - self.lua.pushNumber(self.y); - self.lua.setGlobal("self_y"); + _ = self.lua.rawGetIndex(zlua.registry_index, self.self_ref); + // self table is now at stack -1 + + setNum(self.lua, "x", self.x); + setNum(self.lua, "y", self.y); + + switch (self.kind) { + .rectangle => |r| { + setNum(self.lua, "width", r.width); + setNum(self.lua, "height", r.height); + pushColorTable(self.lua, r.color); // pushes {r,g,b,a} at -1 + self.lua.setField(-2, "color"); // self_tbl.color = color_tbl; pops color_tbl + }, + .base => {}, + } + + self.lua.pop(1); // pop self table } -/// Read back any position changes the script made. +/// Read back Lua self table → Zig state after any callback. fn syncFromLua(self: *Node) void { - _ = self.lua.getGlobal("self_x") catch {}; - self.x = self.lua.toNumber(-1) catch self.x; - self.lua.pop(1); + _ = self.lua.rawGetIndex(zlua.registry_index, self.self_ref); + // self table at -1 - _ = self.lua.getGlobal("self_y") catch {}; - self.y = self.lua.toNumber(-1) catch self.y; - self.lua.pop(1); + self.x = getNum(self.lua, "x", self.x); + self.y = getNum(self.lua, "y", self.y); + + switch (self.kind) { + .rectangle => |*r| { + r.width = getNum(self.lua, "width", r.width); + r.height = getNum(self.lua, "height", r.height); + _ = self.lua.getField(-1, "color"); // stack: [..., self_tbl, color_tbl] + if (self.lua.typeOf(-1) == .table) { + r.color.r = getU8(self.lua, "r", r.color.r); + r.color.g = getU8(self.lua, "g", r.color.g); + r.color.b = getU8(self.lua, "b", r.color.b); + r.color.a = getU8(self.lua, "a", r.color.a); + } + self.lua.pop(1); // pop color_tbl (or nil) + }, + .base => {}, + } + + self.lua.pop(1); // pop self table } -/// Call a zero-arg Lua function by name (no-op if it doesn't exist). -fn callVoid(self: *Node, name: [:0]const u8) void { +// ── Callback helpers ────────────────────────────────────────────────────── + +/// Call lua_fn_name(self) — no-op if the function is not defined. +fn callWithSelf(self: *Node, name: [:0]const u8) void { const t = self.lua.getGlobal(name) catch return; if (t != .function) { self.lua.pop(1); return; } - self.lua.protectedCall(.{}) catch |err| self.logErr(name, err); + _ = self.lua.rawGetIndex(zlua.registry_index, self.self_ref); + self.lua.protectedCall(.{ .args = 1 }) catch |err| self.logErr(name, err); } fn logErr(self: *Node, ctx: []const u8, err: anyerror) void { @@ -116,3 +187,39 @@ fn logErr(self: *Node, ctx: []const u8, err: anyerror) void { std.log.err("[{s}] {s}: {s} ({})", .{ self.script_path, ctx, msg, err }); self.lua.pop(1); } + +/// Set a number field on the table currently at stack -1. +fn setNum(lua: *Lua, key: [:0]const u8, val: f32) void { + lua.pushNumber(@floatCast(val)); + lua.setField(-2, key); // pops num, sets table[-2][key] = num +} + +/// Get a number field from the table currently at stack -1. +fn getNum(lua: *Lua, key: [:0]const u8, default: f32) f32 { + _ = lua.getField(-1, key); + const v = lua.toNumber(-1) catch @as(f64, @floatCast(default)); + lua.pop(1); + return @floatCast(v); +} + +/// Get a u8 color component (0-255) from the table currently at stack -1. +fn getU8(lua: *Lua, key: [:0]const u8, default: u8) u8 { + _ = lua.getField(-1, key); + const v = lua.toNumber(-1) catch @as(f64, @floatFromInt(default)); + lua.pop(1); + return @intFromFloat(std.math.clamp(v, 0, 255)); +} + +/// Push a new {r, g, b, a} table onto the stack. +fn pushColorTable(lua: *Lua, c: rl.Color) void { + lua.newTable(); + lua.pushNumber(@floatFromInt(c.r)); + lua.setField(-2, "r"); + lua.pushNumber(@floatFromInt(c.g)); + lua.setField(-2, "g"); + lua.pushNumber(@floatFromInt(c.b)); + lua.setField(-2, "b"); + lua.pushNumber(@floatFromInt(c.a)); + lua.setField(-2, "a"); + // color table is now at stack top — caller sets it as a field +} diff --git a/src/main.zig b/src/main.zig index d2c875a..0325932 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,7 +16,20 @@ pub fn main() !void { // Attach Lua scripts to nodes — add as many as you like try engine.addNode("Player", "scripts/player.lua"); - try engine.addNode("Box", "scripts/box.lua"); + try engine.addRectNode( + "HealthBar", + 200, + 20, + .{ .r = 60, .g = 200, .b = 80, .a = 255 }, + "scripts/healthbar.lua", + ); + try engine.addRectNode( + "Enemy", + 55, + 55, + .{ .r = 220, .g = 60, .b = 60, .a = 255 }, + "scripts/enemy_rect.lua", + ); while (!rl.windowShouldClose()) { const dt = rl.getFrameTime();