add basic replay

This commit is contained in:
Rokas Puzonas 2026-02-09 00:53:32 +02:00
parent 407da9e009
commit 83ce69a794
5 changed files with 367 additions and 196 deletions

View File

@ -35,7 +35,7 @@ pub fn init(outer_builder: *std.Build, opts: InitOptions) void {
}
const has_imgui = opts.has_imgui orelse (opts.optimize == .Debug);
const statically_linked = opts.statically_linked orelse !hot_reload;
const statically_linked = opts.statically_linked orelse true;
var build_options = b.addOptions();
build_options.addOption(bool, "has_imgui", has_imgui);

View File

@ -286,7 +286,7 @@ pub const Frame = struct {
screen_size: Vec2,
clear_color: Vec4,
pub fn init(gpa: std.mem.Allocator) Frame {
pub fn init(gpa: std.mem.Allocator, screen_size: Vec2) Frame {
return Frame{
.arena = std.heap.ArenaAllocator.init(gpa),
.time_ns = 0,
@ -300,7 +300,7 @@ pub const Frame = struct {
.clear_color = rgb(0, 0, 0),
.audio_commands = .empty,
.hide_cursor = false,
.screen_size = .init(0, 0),
.screen_size = screen_size,
};
}
@ -490,7 +490,8 @@ pub fn exportCallbacksIfNeeded(
comptime deinit: Callbacks.DeinitFn,
comptime debug: Callbacks.DebugFn
) void {
if (build_options.statically_linked) {
const builtin = @import("builtin");
if (builtin.output_mode != .Lib) {
return;
}

View File

@ -24,6 +24,7 @@ const GameCallbacks = Lib.Callbacks;
pub const Math = Lib.Math;
pub const Vec2 = Math.Vec2;
const rgb = Math.rgb;
const Vec4 = Math.Vec4;
const GameLinking = struct {
kind: union(enum) {
@ -246,37 +247,219 @@ const GameLinking = struct {
};
const GameState = struct {
state: GameCallbacks.State,
gpa: std.mem.Allocator,
input: Input,
state: ?GameCallbacks.State,
mouse_position: ?Vec2,
frame: Lib.Frame,
started_at: std.time.Instant,
last_frame_at: Lib.Nanoseconds,
previous_tick_canvas_size: ?Vec2,
seed: u64,
const Options = struct {
gpa: std.mem.Allocator,
const Initial = struct {
screen_size: Vec2,
seed: u64
};
pub fn init(opts: Options) GameState {
const Tick = struct {
dt_ns: u64,
events: []Input.Event,
};
const TickResult = struct {
audio: []Lib.AudioCommand,
graphics: []Lib.GraphicsCommand,
clear_color: Vec4,
cursor_hidden: bool
};
pub fn init(gpa: std.mem.Allocator, initial: Initial) GameState {
return GameState{
.input = .initial,
.frame = .init(opts.gpa),
.gpa = gpa,
.mouse_position = null,
.frame = .init(gpa, initial.screen_size),
.started_at = std.time.Instant.now() catch @panic("Instant.now() unsupported"),
.last_frame_at = 0,
.previous_tick_canvas_size = null,
.seed = opts.seed,
.state = undefined
.seed = initial.seed,
.state = null
};
}
fn processEvent(self: *GameState, event: Input.Event) void {
const frame = &self.frame;
switch (event) {
.key_pressed => |opts| {
if (!opts.repeat) {
frame.keyboard.press(opts.code, self.frame.time_ns);
}
},
.key_released => |key_code| {
frame.keyboard.release(key_code);
},
.mouse_leave => {
frame.keyboard.releaseAll();
assert(self.mouse_position != null);
self.mouse_position = null;
frame.mouse_button = .empty;
},
.mouse_enter => |pos| {
assert(self.mouse_position == null);
self.mouse_position = pos;
},
.mouse_move => |pos| {
assert(self.mouse_position != null);
self.mouse_position = pos;
},
.mouse_pressed => |opts| {
assert(self.mouse_position != null);
self.mouse_position = opts.position;
frame.mouse_button.press(opts.button, frame.time_ns);
},
.mouse_released => |opts| {
assert(self.mouse_position != null);
self.mouse_position = opts.position;
frame.mouse_button.release(opts.button);
},
.window_resize => |opts| {
frame.screen_size = .initFromInt(u32, opts.width, opts.height);
},
else => {}
}
}
fn runGameInit(self: *GameState, init_fn: GameCallbacks.InitFn) void {
const opts = Lib.Init{
.gpa = self.gpa,
.seed = self.seed,
};
self.state = init_fn(&opts);
}
fn runGameDeinit(self: *GameState, deinit_fn: GameCallbacks.DeinitFn) void {
assert(self.state != null);
deinit_fn(self.state.?);
self.state = null;
}
fn runGameTick(self: *GameState, tick_fn: GameCallbacks.TickFn) void {
assert(self.state != null);
tick_fn(self.state.?, &self.frame);
}
fn tick(self: *GameState, tick_fn: GameCallbacks.TickFn, opts: GameState.Tick) !TickResult {
const frame = &self.frame;
_ = frame.arena.reset(.retain_capacity);
const arena = frame.arena.allocator();
const audio_commands_capacity = frame.audio_commands.capacity;
frame.audio_commands = .empty;
try frame.audio_commands.ensureTotalCapacity(arena, audio_commands_capacity);
const graphics_commands_capacity = frame.graphics_commands.capacity;
frame.graphics_commands = .empty;
try frame.graphics_commands.ensureTotalCapacity(arena, graphics_commands_capacity);
frame.dt_ns = opts.dt_ns;
frame.time_ns += opts.dt_ns;
for (opts.events) |event| {
if (self.mouse_position == null) {
if (event == .mouse_leave) {
continue;
}
const enter_position = switch(event) {
.mouse_pressed => |e_opts| e_opts.position,
.mouse_released => |e_opts| e_opts.position,
.mouse_move => |pos| pos,
else => null
};
if (enter_position) |pos| {
self.processEvent(.{ .mouse_enter = pos });
}
} else {
if (event == .mouse_enter) {
continue;
}
}
self.processEvent(event);
}
frame.mouse_position = self.mouse_position;
var maybe_screen_scaler: ?ScreenScalar = null;
if (frame.canvas_size) |canvas_size| {
const screen_scaler = ScreenScalar.init(frame.screen_size, canvas_size);
maybe_screen_scaler = screen_scaler;
screen_scaler.push(frame);
if (frame.mouse_position) |mouse_position| {
frame.mouse_position = mouse_position.sub(screen_scaler.translation).divideScalar(screen_scaler.scale);
}
}
self.runGameTick(tick_fn);
if (maybe_screen_scaler) |screen_scaler| {
screen_scaler.pop(frame, frame.clear_color);
}
frame.keyboard.pressed = .initEmpty();
frame.keyboard.released = .initEmpty();
frame.mouse_button.pressed = .initEmpty();
frame.mouse_button.released = .initEmpty();
return TickResult{
.graphics = self.frame.graphics_commands.items,
.audio = self.frame.audio_commands.items,
.clear_color = self.frame.clear_color,
.cursor_hidden = self.frame.hide_cursor
};
}
fn runGameDebug(self: *GameState, debug_fn: GameCallbacks.DebugFn, imgui_ctx: *Lib.ImGui) void {
assert(self.state != null);
debug_fn(self.state.?, imgui_ctx);
}
pub fn deinit(self: *GameState) void {
self.frame.deinit();
}
};
const Recording = struct {
arena: std.heap.ArenaAllocator,
initial: GameState.Initial,
ticks: std.ArrayList(GameState.Tick),
pub fn init(gpa: std.mem.Allocator, initial: GameState.Initial) Recording {
return Recording{
.arena = .init(gpa),
.ticks = .empty,
.initial = initial
};
}
pub fn appendTick(self: *Recording, tick: GameState.Tick) !void {
const arena = self.arena.allocator();
try self.ticks.append(arena, .{
.dt_ns = tick.dt_ns,
.events = try arena.dupe(Input.Event, tick.events)
});
}
pub fn deinit(self: Recording) void {
self.arena.deinit();
}
};
const Engine = @This();
allocator: std.mem.Allocator,
@ -285,31 +468,58 @@ graphics: Graphics,
game_linking: GameLinking,
game_state: GameState,
queued_events: std.ArrayList(Input.Event),
is_mouse_inside: bool,
seed: u64,
show_debug: bool,
restart_game: bool,
recording: ?Recording,
play_recording: bool,
recording_tick: u64,
const RunOptions = struct {
allocator: std.mem.Allocator,
game_library_path: ?[]const u8 = null
game_library_path: ?[]const u8 = null,
do_recording: bool = (builtin.mode == .Debug),
screen_width: u32 = 640,
screen_height: u32 = 480,
};
pub fn run(self: *Engine, opts: RunOptions) !void {
var game_linking: GameLinking = undefined;
if (opts.game_library_path) |game_library_path| {
game_linking = try .initDynamic(opts.allocator, game_library_path);
} else {
game_linking = try .initStatic();
}
const initial = GameState.Initial{
.seed = @bitCast(std.time.milliTimestamp()),
.screen_size = .initFromInt(u32, opts.screen_width, opts.screen_height)
};
var game_state: GameState = .init(opts.allocator, initial);
game_state.runGameInit(game_linking.callbacks.init);
self.* = Engine{
.allocator = opts.allocator,
.graphics = undefined,
.game_state = undefined,
.game_linking = undefined,
.game_state = game_state,
.game_linking = game_linking,
.show_debug = false,
.restart_game = false,
.seed = @bitCast(std.time.milliTimestamp()),
.recording = null,
.queued_events = .empty,
.is_mouse_inside = false,
.play_recording = false,
.recording_tick = 0,
.seed = initial.seed,
};
if (opts.game_library_path) |game_library_path| {
self.game_linking = try .initDynamic(self.allocator, game_library_path);
} else {
self.game_linking = try .initStatic();
if (opts.do_recording) {
self.recording = .init(opts.allocator, initial);
}
tracy.setThreadName("Main");
@ -350,8 +560,8 @@ pub fn run(self: *Engine, opts: RunOptions) !void {
.cleanup_userdata_cb = sokolCleanupCallback,
.event_userdata_cb = sokolEventCallback,
.user_data = self,
.width = 640,
.height = 480,
.width = @intCast(opts.screen_width),
.height = @intCast(opts.screen_height),
.icon = icon,
.window_title = "Game",
.logger = .{ .func = sokolLogCallback },
@ -378,24 +588,18 @@ fn sokolInit(self: *Engine) !void {
.allocator = self.allocator,
.logger = .{ .func = sokolLogCallback },
});
self.game_state = .init(.{
.gpa = self.allocator,
.seed = self.seed
});
const opts = Lib.Init{
.gpa = self.allocator,
.seed = self.game_state.seed,
};
self.game_state.state = self.game_linking.callbacks.init(&opts);
}
fn sokolCleanup(self: *Engine) void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
self.game_linking.callbacks.deinit(self.game_state.state);
if (self.recording) |recording| {
recording.deinit();
}
self.queued_events.deinit(self.allocator);
self.game_state.runGameDeinit(self.game_linking.callbacks.deinit);
self.game_state.deinit();
Audio.deinit();
self.graphics.deinit(self.allocator);
@ -408,105 +612,79 @@ fn sokolFrame(self: *Engine) !void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
const frame = &self.game_state.frame;
try self.game_linking.check();
const screen_size = Vec2.init(sapp.widthf(), sapp.heightf());
const now = std.time.Instant.now() catch @panic("Instant.now() unsupported");
const time_passed = now.since(self.game_state.started_at);
const dt_ns = time_passed - self.game_state.last_frame_at;
self.game_state.last_frame_at = time_passed;
var maybe_screen_scaler: ?ScreenScalar = null;
if (self.game_state.previous_tick_canvas_size) |canvas_size| {
maybe_screen_scaler = ScreenScalar.init(screen_size, canvas_size);
if (self.play_recording) {
assert(self.recording != null);
if (self.recording_tick >= self.recording.?.ticks.items.len) {
self.play_recording = false;
}
}
var tick: GameState.Tick = undefined;
if (self.play_recording) {
assert(self.recording != null);
const ticks = self.recording.?.ticks.items;
tick = ticks[self.recording_tick];
self.recording_tick += 1;
} else {
tick = GameState.Tick{
.dt_ns = dt_ns,
.events = self.queued_events.items
};
if (self.recording) |*recording| {
try recording.appendTick(tick);
}
}
const tick_result = try self.game_state.tick(self.game_linking.callbacks.tick, tick);
self.queued_events.clearRetainingCapacity();
{
const now = std.time.Instant.now() catch @panic("Instant.now() unsupported");
const time_passed = now.since(self.game_state.started_at);
defer self.game_state.last_frame_at = time_passed;
sapp.showMouse(!tick_result.cursor_hidden);
_ = frame.arena.reset(.retain_capacity);
const arena = frame.arena.allocator();
{
self.graphics.beginFrame();
defer self.graphics.endFrame(tick_result.clear_color);
const audio_commands_capacity = frame.audio_commands.capacity;
frame.audio_commands = .empty;
try frame.audio_commands.ensureTotalCapacity(arena, audio_commands_capacity);
self.graphics.drawCommands(tick_result.graphics);
const graphics_commands_capacity = frame.graphics_commands.capacity;
frame.graphics_commands = .empty;
try frame.graphics_commands.ensureTotalCapacity(arena, graphics_commands_capacity);
frame.screen_size = screen_size;
frame.time_ns = time_passed;
frame.dt_ns = time_passed - self.game_state.last_frame_at;
frame.mouse_position = self.game_state.input.mouse_position;
if (maybe_screen_scaler) |screen_scaler| {
screen_scaler.push(frame);
if (frame.mouse_position) |mouse_position| {
frame.mouse_position = mouse_position.sub(screen_scaler.translation).divideScalar(screen_scaler.scale);
if (self.show_debug and build_options.has_imgui) {
try self.showDebugWindow(&self.game_state.frame);
}
}
if (debug_keybinds) {
if (frame.isKeyPressed(.F3)) {
self.show_debug = !self.show_debug;
}
if (frame.isKeyPressed(.F5)) {
self.restart_game = true;
}
for (tick_result.audio) |command| {
try Audio.mixer.commands.push(command);
}
self.game_linking.callbacks.tick(self.game_state.state, frame);
if (maybe_screen_scaler) |screen_scaler| {
screen_scaler.pop(frame, frame.clear_color);
}
frame.keyboard.pressed = .initEmpty();
frame.keyboard.released = .initEmpty();
frame.mouse_button.pressed = .initEmpty();
frame.mouse_button.released = .initEmpty();
}
// Canvas size modification must always be applied a frame later.
// So that mouse coordinate transformations are consistent.
self.game_state.previous_tick_canvas_size = frame.canvas_size;
sapp.showMouse(!frame.hide_cursor);
{
self.graphics.beginFrame();
defer self.graphics.endFrame(frame.clear_color);
self.graphics.drawCommands(frame.graphics_commands.items);
if (self.show_debug and build_options.has_imgui) {
try self.showDebugWindow(frame);
}
}
for (frame.audio_commands.items) |command| {
try Audio.mixer.commands.push(command);
}
if (self.restart_game) {
self.restart_game = false;
self.game_linking.callbacks.deinit(self.game_state.state);
self.game_state.deinit();
self.game_state = .init(.{
.gpa = self.allocator,
.seed = self.seed
});
const opts = Lib.Init{
.gpa = self.allocator,
.seed = self.game_state.seed,
const initial = GameState.Initial{
.seed = self.seed,
.screen_size = .init(sapp.widthf(), sapp.heightf())
};
self.game_state.state = self.game_linking.callbacks.init(&opts);
self.game_state.runGameDeinit(self.game_linking.callbacks.deinit);
self.game_state.deinit();
self.game_state = .init(self.allocator, initial);
self.game_state.runGameInit(self.game_linking.callbacks.init);
if (self.recording) |recording| {
recording.deinit();
self.recording = .init(self.allocator, initial);
}
}
}
@ -528,7 +706,8 @@ fn showDebugWindow(self: *Engine, frame: *Lib.Frame) !void {
var imgui_ctx = Lib.ImGui.init(self.allocator);
defer imgui_ctx.deinit();
self.game_linking.callbacks.debug(self.game_state.state, &imgui_ctx);
self.game_state.runGameDebug(self.game_linking.callbacks.debug, &imgui_ctx);
for (imgui_ctx.commands.items) |cmd| {
switch (cmd) {
.text => |str| imgui.text(str)
@ -543,6 +722,22 @@ fn showDebugWindow(self: *Engine, frame: *Lib.Frame) !void {
self.restart_game = true;
}
if (self.recording) |recording| {
imgui.beginDisabled(self.play_recording);
defer imgui.endDisabled();
if (imgui.button("Replay")) {
self.game_state.runGameDeinit(self.game_linking.callbacks.deinit);
self.game_state.deinit();
self.game_state = .init(self.allocator, recording.initial);
self.game_state.runGameInit(self.game_linking.callbacks.init);
self.play_recording = true;
self.recording_tick = 0;
}
}
const linking_label = if (self.game_linking.kind == .static) "static" else "dynamic";
imgui.textFmt("Linking: {s}", .{ linking_label });
@ -567,24 +762,38 @@ fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
const e = e_ptr.*;
const input = &self.game_state.input;
const frame = &self.game_state.frame;
if (imgui.handleEvent(e)) {
if (input.mouse_position != null) {
input.processEvent(frame, .{
if (self.is_mouse_inside) {
try self.queued_events.append(self.allocator, .{
.mouse_leave = {}
});
self.is_mouse_inside = false;
}
input.mouse_position = null;
return true;
}
if (debug_keybinds and e.type == .KEY_DOWN) {
if (e.key_code == .F4) {
self.show_debug = !self.show_debug;
return true;
}
if (e.key_code == .F5) {
self.restart_game = true;
return true;
}
}
if (self.play_recording) {
return false;
}
blk: switch (e.type) {
.MOUSE_DOWN => {
const mouse_button = Input.getMouseButtonFromSokol(e.mouse_button) orelse break :blk;
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.mouse_pressed = .{
.button = mouse_button,
.position = Vec2.init(e.mouse_x, e.mouse_y)
@ -596,7 +805,7 @@ fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
.MOUSE_UP => {
const mouse_button = Input.getMouseButtonFromSokol(e.mouse_button) orelse break :blk;
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.mouse_released = .{
.button = mouse_button,
.position = Vec2.init(e.mouse_x, e.mouse_y)
@ -606,48 +815,57 @@ fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
return true;
},
.MOUSE_MOVE => {
if (input.mouse_position == null) {
input.processEvent(frame, .{
.mouse_enter = Vec2.init(e.mouse_x, e.mouse_y)
});
} else {
input.processEvent(frame, .{
if (self.is_mouse_inside) {
try self.queued_events.append(self.allocator, .{
.mouse_move = Vec2.init(e.mouse_x, e.mouse_y)
});
} else {
try self.queued_events.append(self.allocator, .{
.mouse_enter = Vec2.init(e.mouse_x, e.mouse_y)
});
self.is_mouse_inside = true;
}
return true;
},
.MOUSE_ENTER => {
if (input.mouse_position == null) {
input.processEvent(frame, .{
if (!self.is_mouse_inside) {
try self.queued_events.append(self.allocator, .{
.mouse_enter = Vec2.init(e.mouse_x, e.mouse_y)
});
self.is_mouse_inside = true;
}
return true;
},
.RESIZED => {
if (input.mouse_position != null) {
input.processEvent(frame, .{
if (self.is_mouse_inside) {
try self.queued_events.append(self.allocator, .{
.mouse_leave = {}
});
self.is_mouse_inside = false;
}
input.processEvent(frame, .{
.window_resize = {}
try self.queued_events.append(self.allocator, .{
.window_resize = .{
.width = @intCast(e.window_width),
.height = @intCast(e.window_height)
}
});
return true;
},
.MOUSE_LEAVE => {
if (input.mouse_position != null) {
input.processEvent(frame, .{
if (self.is_mouse_inside) {
try self.queued_events.append(self.allocator, .{
.mouse_leave = {}
});
self.is_mouse_inside = false;
}
return true;
},
.MOUSE_SCROLL => {
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.mouse_scroll = Vec2.init(e.scroll_x, e.scroll_y)
});
@ -656,7 +874,7 @@ fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
.KEY_DOWN => {
const key_code = Input.getKeyCodeFromSokol(e.key_code) orelse break :blk;
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.key_pressed = .{
.code = key_code,
.repeat = e.key_repeat
@ -668,14 +886,14 @@ fn sokolEvent(self: *Engine, e_ptr: [*c]const sapp.Event) !bool {
.KEY_UP => {
const key_code = Input.getKeyCodeFromSokol(e.key_code) orelse break :blk;
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.key_released = key_code
});
return true;
},
.CHAR => {
input.processEvent(frame, .{
try self.queued_events.append(self.allocator, .{
.char = @intCast(e.char_code)
});

View File

@ -251,9 +251,6 @@ pub fn drawCommands(self: *Graphics, commands: []const Command) void {
}
pub fn beginFrame(self: *Graphics) void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
imgui.newFrame(.{
.width = sapp.width(),
.height = sapp.height(),
@ -275,9 +272,6 @@ pub fn beginFrame(self: *Graphics) void {
}
pub fn endFrame(self: *Graphics, clear_color: Vec4) void {
const zone = tracy.initZone(@src(), .{ });
defer zone.deinit();
var pass_action: sg.PassAction = .{};
pass_action.colors[0] = sg.ColorAttachmentAction{

View File

@ -7,8 +7,6 @@ const Frame = lib.Frame;
const Nanoseconds = lib.Nanoseconds;
const Vec2 = lib.Math.Vec2;
const Input = @This();
pub const Event = union(enum) {
mouse_pressed: struct {
button: lib.MouseButton,
@ -27,54 +25,14 @@ pub const Event = union(enum) {
repeat: bool
},
key_released: lib.KeyCode,
window_resize,
window_resize: struct {
width: u32,
height: u32,
},
char: u21,
};
mouse_position: ?Vec2,
pub const initial = Input{
.mouse_position = null
};
pub fn processEvent(self: *Input, frame: *Frame, event: Event) void {
switch (event) {
.key_pressed => |opts| {
if (!opts.repeat) {
frame.keyboard.press(opts.code, frame.time_ns);
}
},
.key_released => |key_code| {
frame.keyboard.release(key_code);
},
.mouse_leave => {
frame.keyboard.releaseAll();
assert(self.mouse_position != null);
self.mouse_position = null;
frame.mouse_button = .empty;
},
.mouse_enter => |pos| {
assert(self.mouse_position == null);
self.mouse_position = pos;
},
.mouse_move => |pos| {
assert(self.mouse_position != null);
self.mouse_position = pos;
},
.mouse_pressed => |opts| {
assert(self.mouse_position != null);
self.mouse_position = opts.position;
frame.mouse_button.press(opts.button, frame.time_ns);
},
.mouse_released => |opts| {
assert(self.mouse_position != null);
self.mouse_position = opts.position;
frame.mouse_button.release(opts.button);
},
else => {}
}
}
pub const EventEnum = @typeInfo(Event).@"union".tag_type.?;
pub fn getKeyCodeFromSokol(key_code: sokol.app.Keycode) ?lib.KeyCode {
return switch (key_code) {