commit 0c04307db8b35ca1e4f760e668dce8056c623a82 Author: adrien Date: Wed Apr 8 22:57:33 2026 +0200 Base commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8c8979 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.zig-cache +zig-out diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..db9ecda --- /dev/null +++ b/build.zig @@ -0,0 +1,52 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lua_dep = b.dependency("zlua", .{ + .target = target, + .optimize = optimize, + }); + + const raylib_dep = b.dependency("raylib_zig", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "engine", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{}, + }), + }); + + exe.root_module.linkLibrary(raylib_dep.artifact("raylib")); + exe.root_module.addImport("raylib", raylib_dep.module("raylib")); + exe.root_module.addImport("zlua", lua_dep.module("zlua")); + + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + const run_exe_tests = b.addRunArtifact(exe_tests); + + const test_step = b.step("test", "Run tests"); + test_step.dependOn(&run_exe_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..7560a72 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,21 @@ +.{ + .name = .engine, + .version = "0.0.0", + .fingerprint = 0xe8a81a8d12d08408, // Changing this has security and trust implications. + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .zlua = .{ + .url = "git+https://github.com/natecraddock/ziglua#9f5f3db7ed893000e44badc073e0f3632b731021", + .hash = "zlua-0.1.0-hGRpC-xTBQDwe5Mu1zKV5SB2VOY9AmSco05_vurY5jGh", + }, + .raylib_zig = .{ + .url = "git+https://github.com/raylib-zig/raylib-zig#1e257d1738b4ee25fe76ea1b1bd8b5ea2bf639c4", + .hash = "raylib_zig-5.6.0-dev-KE8RECxEBQDwM9vp3RY9uvOduewuez61dojPyNO_L9Ph", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/scripts/box.lua b/scripts/box.lua new file mode 100644 index 0000000..8b4f213 --- /dev/null +++ b/scripts/box.lua @@ -0,0 +1,34 @@ +-- 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/player.lua b/scripts/player.lua new file mode 100644 index 0000000..48add13 --- /dev/null +++ b/scripts/player.lua @@ -0,0 +1,42 @@ +-- 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) + +local speed = 200 +local width = 40 +local height = 50 +local cr, cg, cb = 80, 160, 255 -- ← try changing this color and saving! + +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. +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 + + -- 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)) +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) +end diff --git a/src/Engine.zig b/src/Engine.zig new file mode 100644 index 0000000..f3145a2 --- /dev/null +++ b/src/Engine.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const rl = @import("raylib"); +const Node = @import("Node.zig"); + +const Engine = @This(); + +allocator: std.mem.Allocator, +nodes: std.ArrayList(Node) = .{}, + +pub fn init(allocator: std.mem.Allocator) !Engine { + return .{ + .allocator = allocator, + }; +} + +pub fn deinit(self: *Engine) void { + for (self.nodes.items) |*n| n.deinit(); + self.nodes.deinit(self.allocator); +} + +pub fn addNode(self: *Engine, name: []const u8, script: []const u8) !void { + const node = try Node.init(self.allocator, name, script); + try self.nodes.append(self.allocator, node); +} + +/// Poll every node's file watcher; reload changed scripts. +pub fn checkReloads(self: *Engine) !void { + for (self.nodes.items) |*node| { + if (node.watcher.poll()) try node.loadScript(); + } +} + +pub fn update(self: *Engine, dt: f32) void { + for (self.nodes.items) |*n| n.update(dt); +} + +pub fn render(self: *Engine) void { + for (self.nodes.items) |*n| n.render(); +} + +/// Editor overlay: simple node-list panel on the right edge. +pub fn drawEditorUI(self: *Engine) void { + const px: i32 = 1280 - 220; + rl.drawRectangle(px, 0, 220, 720, .{ .r = 20, .g = 20, .b = 20, .a = 210 }); + rl.drawText("NODES", px + 10, 10, 12, rl.Color.gray); + + for (self.nodes.items, 0..) |*node, i| { + const y: i32 = 32 + @as(i32, @intCast(i)) * 44; + rl.drawRectangle(px + 4, y, 212, 38, .{ .r = 45, .g = 45, .b = 45, .a = 255 }); + + const name_z = std.fmt.allocPrintSentinel(self.allocator, "{s}", .{node.name}, 0) catch continue; + defer self.allocator.free(name_z); + rl.drawText(name_z, px + 10, y + 4, 13, rl.Color.white); + + const path_z = std.fmt.allocPrintSentinel(self.allocator, "{s}", .{node.script_path}, 0) catch continue; + defer self.allocator.free(path_z); + rl.drawText(path_z, px + 10, y + 22, 10, rl.Color.light_gray); + } + + rl.drawText("Edit .lua to hot reload", px + 6, 700, 9, rl.Color.dark_gray); +} diff --git a/src/FileWatcher.zig b/src/FileWatcher.zig new file mode 100644 index 0000000..59ba211 --- /dev/null +++ b/src/FileWatcher.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +/// Polls a single file's mtime to detect saves. +/// Cross-platform — works on Linux, macOS, Windows. +const FileWatcher = @This(); + +path: []const u8, +last_mtime: i128 = 0, + +pub fn init(path: []const u8) FileWatcher { + var w = FileWatcher{ .path = path }; + w.last_mtime = w.getMtime() catch 0; + return w; +} + +/// Returns true (and updates internal mtime) if the file changed. +pub fn poll(self: *FileWatcher) bool { + const mtime = self.getMtime() catch return false; + if (mtime != self.last_mtime) { + self.last_mtime = mtime; + return true; + } + return false; +} + +fn getMtime(self: *const FileWatcher) !i128 { + const stat = try std.fs.cwd().statFile(self.path); + return stat.mtime; +} diff --git a/src/Node.zig b/src/Node.zig new file mode 100644 index 0000000..c813bfe --- /dev/null +++ b/src/Node.zig @@ -0,0 +1,118 @@ +const std = @import("std"); +const zlua = @import("zlua"); +const Lua = zlua.Lua; +const lua_api = @import("lua_api.zig"); +const FileWatcher = @import("FileWatcher.zig"); + +/// A scene node with an attached Lua script. +/// Each node owns a dedicated Lua state → scripts are fully isolated. +const Node = @This(); + +allocator: std.mem.Allocator, +name: []const u8, +script_path: []const u8, +lua: *Lua, +watcher: FileWatcher, + +// Node transform — synced to/from Lua as self_x, self_y globals +x: f64 = 400, +y: f64 = 300, + +pub fn init( + allocator: std.mem.Allocator, + name: []const u8, + script_path: []const u8, +) !Node { + const lua = try Lua.init(allocator); + lua.openLibs(); + lua_api.registerAll(lua); // expose engine API to this state + + var node = Node{ + .allocator = allocator, + .name = name, + .script_path = script_path, + .lua = lua, + .watcher = FileWatcher.init(script_path), + }; + try node.loadScript(); + return node; +} + +pub fn deinit(self: *Node) void { + self.lua.deinit(); +} + +/// Load (or hot-reload) the Lua script from disk. +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 }); + self.lua.pop(1); + return; // keep running with old code rather than crashing + }; + + self.syncToLua(); // give on_init() the current position + self.callVoid("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; + if (t != .function) { + self.lua.pop(1); + return; + } + self.lua.pushNumber(@floatCast(dt)); + self.lua.protectedCall(.{ .args = 1 }) catch |err| self.logErr("on_update", err); + self.syncFromLua(); +} + +/// Called every frame after update. +pub fn render(self: *Node) void { + self.syncToLua(); + self.callVoid("on_render"); +} + +// ── internals ────────────────────────────────────────────────────────── + +/// Write node state into Lua globals so scripts can read/modify them. +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"); +} + +/// Read back any position changes the script made. +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.getGlobal("self_y") catch {}; + self.y = self.lua.toNumber(-1) catch self.y; + self.lua.pop(1); +} + +/// Call a zero-arg Lua function by name (no-op if it doesn't exist). +fn callVoid(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); +} + +fn logErr(self: *Node, ctx: []const u8, err: anyerror) void { + const msg = self.lua.toString(-1) catch "?"; + std.log.err("[{s}] {s}: {s} ({})", .{ self.script_path, ctx, msg, err }); + self.lua.pop(1); +} diff --git a/src/lua_api.zig b/src/lua_api.zig new file mode 100644 index 0000000..4c90dcd --- /dev/null +++ b/src/lua_api.zig @@ -0,0 +1,132 @@ +const std = @import("std"); +const rl = @import("raylib"); +const zlua = @import("zlua"); +const Lua = zlua.Lua; + +/// Register all engine API functions + constants into a Lua state. +pub fn registerAll(lua: *Lua) void { + // Drawing + reg(lua, "draw_rect", drawRect); + reg(lua, "draw_circle", drawCircle); + reg(lua, "draw_text", drawText); + reg(lua, "draw_line", drawLine); + // Input + reg(lua, "key_down", keyDown); + reg(lua, "key_pressed", keyPressed); + reg(lua, "mouse_down", mouseDown); + reg(lua, "mouse_x", mouseX); + reg(lua, "mouse_y", mouseY); + // Util + reg(lua, "log", luaLog); + + // Key constants table: Key.LEFT, Key.W, Key.SPACE, ... + lua.newTable(); + keyConst(lua, "LEFT", .left); + keyConst(lua, "RIGHT", .right); + keyConst(lua, "UP", .up); + keyConst(lua, "DOWN", .down); + keyConst(lua, "SPACE", .space); + keyConst(lua, "W", .w); + keyConst(lua, "A", .a); + keyConst(lua, "S", .s); + keyConst(lua, "D", .d); + lua.setGlobal("Key"); +} + +fn reg(lua: *Lua, name: [:0]const u8, comptime f: fn (*Lua) i32) void { + lua.pushFunction(zlua.wrap(f)); + lua.setGlobal(name); +} +fn keyConst(lua: *Lua, name: [:0]const u8, key: rl.KeyboardKey) void { + _ = lua.pushString(name); + lua.pushInteger(@intFromEnum(key)); + lua.setTable(-3); +} + +// ── Drawing ──────────────────────────────────────────────────────────────── +// Helpers to read color args from Lua stack at a given base index +fn argColor(lua: *Lua, base: i32) rl.Color { + return .{ + .r = @intFromFloat(lua.toNumber(base) catch 255), + .g = @intFromFloat(lua.toNumber(base + 1) catch 255), + .b = @intFromFloat(lua.toNumber(base + 2) catch 255), + .a = @intFromFloat(lua.toNumber(base + 3) catch 255), + }; +} +// draw_rect(x, y, w, h, r, g, b [,a]) +fn drawRect(lua: *Lua) i32 { + rl.drawRectangle( + @intFromFloat(lua.toNumber(1) catch 0), + @intFromFloat(lua.toNumber(2) catch 0), + @intFromFloat(lua.toNumber(3) catch 10), + @intFromFloat(lua.toNumber(4) catch 10), + argColor(lua, 5), + ); + return 0; +} +// draw_circle(x, y, radius, r, g, b [,a]) +fn drawCircle(lua: *Lua) i32 { + rl.drawCircleV(.{ + .x = @floatCast(lua.toNumber(1) catch 0), + .y = @floatCast(lua.toNumber(2) catch 0), + }, @floatCast(lua.toNumber(3) catch 10), argColor(lua, 4)); + return 0; +} +// draw_text(text, x, y, size, r, g, b [,a]) +fn drawText(lua: *Lua) i32 { + const text = lua.toString(1) catch return 0; + rl.drawText( + text, + @intFromFloat(lua.toNumber(2) catch 0), + @intFromFloat(lua.toNumber(3) catch 0), + @intFromFloat(lua.toNumber(4) catch 20), + argColor(lua, 5), + ); + return 0; +} +// draw_line(x1, y1, x2, y2, r, g, b [,a]) +fn drawLine(lua: *Lua) i32 { + rl.drawLine( + @intFromFloat(lua.toNumber(1) catch 0), + @intFromFloat(lua.toNumber(2) catch 0), + @intFromFloat(lua.toNumber(3) catch 0), + @intFromFloat(lua.toNumber(4) catch 0), + argColor(lua, 5), + ); + return 0; +} + +// ── Input ───────────────────────────────────────────────────────────────── +// key_down(Key.X) → bool +fn keyDown(lua: *Lua) i32 { + const k = lua.toInteger(1) catch return 0; + lua.pushBoolean(rl.isKeyDown(@enumFromInt(k))); + return 1; +} +// key_pressed(Key.X) → bool (true only on the frame it was pressed) +fn keyPressed(lua: *Lua) i32 { + const k = lua.toInteger(1) catch return 0; + lua.pushBoolean(rl.isKeyPressed(@enumFromInt(k))); + return 1; +} +// mouse_down(0|1|2) → bool 0=left 1=right 2=middle +fn mouseDown(lua: *Lua) i32 { + const b = lua.toInteger(1) catch 0; + lua.pushBoolean(rl.isMouseButtonDown(@enumFromInt(b))); + return 1; +} +fn mouseX(lua: *Lua) i32 { + lua.pushNumber(@floatFromInt(rl.getMouseX())); + return 1; +} +fn mouseY(lua: *Lua) i32 { + lua.pushNumber(@floatFromInt(rl.getMouseY())); + return 1; +} + +// ── Util ────────────────────────────────────────────────────────────────── +fn luaLog(lua: *Lua) i32 { + const msg = lua.toString(1) catch "nil"; + std.log.info("[lua] {s}", .{msg}); + return 0; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..d2c875a --- /dev/null +++ b/src/main.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const rl = @import("raylib"); +const Engine = @import("Engine.zig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + rl.initWindow(1280, 720, "Zig + Raylib + Lua Engine"); + rl.setTargetFPS(60); + defer rl.closeWindow(); + + var engine = try Engine.init(allocator); + defer engine.deinit(); + + // 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"); + + while (!rl.windowShouldClose()) { + const dt = rl.getFrameTime(); + + // Poll file mtimes — reloads any script that changed on disk + try engine.checkReloads(); + + engine.update(dt); // calls on_update(dt) in each script + + rl.beginDrawing(); + defer rl.endDrawing(); + rl.clearBackground(rl.Color.black); + + engine.render(); // calls on_render() in each script + engine.drawEditorUI(); // node-list panel (right side) + } +}