Base commit

This commit is contained in:
adrien 2026-04-08 22:57:33 +02:00
commit 0c04307db8
10 changed files with 527 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.zig-cache
zig-out

52
build.zig Normal file
View File

@ -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);
}

21
build.zig.zon Normal file
View File

@ -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",
},
}

34
scripts/box.lua Normal file
View File

@ -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

42
scripts/player.lua Normal file
View File

@ -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

61
src/Engine.zig Normal file
View File

@ -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);
}

29
src/FileWatcher.zig Normal file
View File

@ -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;
}

118
src/Node.zig Normal file
View File

@ -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);
}

132
src/lua_api.zig Normal file
View File

@ -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;
}

36
src/main.zig Normal file
View File

@ -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)
}
}