Create a sandbox with spawn controls
This commit is contained in:
parent
89b75a2c54
commit
1f85f329f2
4 changed files with 443 additions and 8 deletions
16
TODO.md
16
TODO.md
|
|
@ -4,14 +4,14 @@ survivor-like optimized for weak hardware. finding the performance ceiling first
|
||||||
|
|
||||||
## phase 1: sandbox stress test
|
## phase 1: sandbox stress test
|
||||||
|
|
||||||
- [ ] create sandbox.zig (separate from existing game code)
|
- [x] create sandbox.zig (separate from existing game code)
|
||||||
- [ ] entity struct (x, y, vx, vy, color)
|
- [x] entity struct (x, y, vx, vy, color)
|
||||||
- [ ] flat array storage for entities
|
- [x] flat array storage for entities
|
||||||
- [ ] spawn entities at random screen edges
|
- [x] spawn entities at random screen edges
|
||||||
- [ ] update loop: move toward center, respawn on arrival
|
- [x] update loop: move toward center, respawn on arrival
|
||||||
- [ ] render: filled circles (4px radius, cyan)
|
- [x] render: filled circles (4px radius, cyan)
|
||||||
- [ ] metrics overlay (entity count, frame time, update time, render time)
|
- [x] metrics overlay (entity count, frame time, update time, render time)
|
||||||
- [ ] controls: +/- 100, shift +/- 1000, space pause, r reset
|
- [x] controls: +/- 100, shift +/- 1000, space pause, r reset
|
||||||
|
|
||||||
## phase 2: find the ceiling
|
## phase 2: find the ceiling
|
||||||
|
|
||||||
|
|
|
||||||
25
build.zig
25
build.zig
|
|
@ -32,6 +32,30 @@ pub fn build(b: *std.Build) void {
|
||||||
const run_step = b.step("run", "run the game");
|
const run_step = b.step("run", "run the game");
|
||||||
run_step.dependOn(&run_cmd.step);
|
run_step.dependOn(&run_cmd.step);
|
||||||
|
|
||||||
|
// sandbox executable
|
||||||
|
const sandbox_exe = b.addExecutable(.{
|
||||||
|
.name = "sandbox",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/sandbox_main.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
sandbox_exe.root_module.addImport("raylib", raylib_dep.module("raylib"));
|
||||||
|
sandbox_exe.linkLibrary(raylib_dep.artifact("raylib"));
|
||||||
|
|
||||||
|
b.installArtifact(sandbox_exe);
|
||||||
|
|
||||||
|
const sandbox_run_cmd = b.addRunArtifact(sandbox_exe);
|
||||||
|
sandbox_run_cmd.step.dependOn(b.getInstallStep());
|
||||||
|
if (b.args) |args| {
|
||||||
|
sandbox_run_cmd.addArgs(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sandbox_step = b.step("sandbox", "run the sandbox stress test");
|
||||||
|
sandbox_step.dependOn(&sandbox_run_cmd.step);
|
||||||
|
|
||||||
// test step (doesn't need raylib)
|
// test step (doesn't need raylib)
|
||||||
const test_step = b.step("test", "run unit tests");
|
const test_step = b.step("test", "run unit tests");
|
||||||
|
|
||||||
|
|
@ -40,6 +64,7 @@ pub fn build(b: *std.Build) void {
|
||||||
"src/trig.zig",
|
"src/trig.zig",
|
||||||
"src/terrain.zig",
|
"src/terrain.zig",
|
||||||
"src/game.zig",
|
"src/game.zig",
|
||||||
|
"src/sandbox.zig",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (test_files) |file| {
|
for (test_files) |file| {
|
||||||
|
|
|
||||||
280
src/sandbox.zig
Normal file
280
src/sandbox.zig
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
// sandbox stress test
|
||||||
|
// measures entity count ceiling on weak hardware
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const SCREEN_WIDTH: u32 = 1280;
|
||||||
|
pub const SCREEN_HEIGHT: u32 = 1024;
|
||||||
|
const CENTER_X: f32 = @as(f32, @floatFromInt(SCREEN_WIDTH)) / 2.0;
|
||||||
|
const CENTER_Y: f32 = @as(f32, @floatFromInt(SCREEN_HEIGHT)) / 2.0;
|
||||||
|
const RESPAWN_THRESHOLD: f32 = 10.0;
|
||||||
|
const ENTITY_SPEED: f32 = 2.0;
|
||||||
|
|
||||||
|
pub const Entity = struct {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
vx: f32,
|
||||||
|
vy: f32,
|
||||||
|
color: u32,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_ENTITIES: usize = 100_000;
|
||||||
|
|
||||||
|
pub const Entities = struct {
|
||||||
|
items: [MAX_ENTITIES]Entity,
|
||||||
|
count: usize,
|
||||||
|
|
||||||
|
pub fn init() Entities {
|
||||||
|
return .{
|
||||||
|
.items = undefined,
|
||||||
|
.count = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(self: *Entities, rng: *std.Random) void {
|
||||||
|
if (self.count >= MAX_ENTITIES) return;
|
||||||
|
self.items[self.count] = spawnAtEdge(rng);
|
||||||
|
self.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(self: *Entities, n: usize) void {
|
||||||
|
if (n >= self.count) {
|
||||||
|
self.count = 0;
|
||||||
|
} else {
|
||||||
|
self.count -= n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(self: *Entities) void {
|
||||||
|
self.count = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn update(entities: *Entities, rng: *std.Random) void {
|
||||||
|
for (entities.items[0..entities.count]) |*entity| {
|
||||||
|
// apply velocity
|
||||||
|
entity.x += entity.vx;
|
||||||
|
entity.y += entity.vy;
|
||||||
|
|
||||||
|
// check if reached center
|
||||||
|
const dx = entity.x - CENTER_X;
|
||||||
|
const dy = entity.y - CENTER_Y;
|
||||||
|
const dist = @sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < RESPAWN_THRESHOLD) {
|
||||||
|
// respawn at random edge
|
||||||
|
entity.* = spawnAtEdge(rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawnAtEdge(rng: *std.Random) Entity {
|
||||||
|
// pick random edge: 0=top, 1=bottom, 2=left, 3=right
|
||||||
|
const edge = rng.intRangeAtMost(u8, 0, 3);
|
||||||
|
const screen_w = @as(f32, @floatFromInt(SCREEN_WIDTH));
|
||||||
|
const screen_h = @as(f32, @floatFromInt(SCREEN_HEIGHT));
|
||||||
|
|
||||||
|
var x: f32 = undefined;
|
||||||
|
var y: f32 = undefined;
|
||||||
|
|
||||||
|
switch (edge) {
|
||||||
|
0 => { // top
|
||||||
|
x = rng.float(f32) * screen_w;
|
||||||
|
y = 0;
|
||||||
|
},
|
||||||
|
1 => { // bottom
|
||||||
|
x = rng.float(f32) * screen_w;
|
||||||
|
y = screen_h;
|
||||||
|
},
|
||||||
|
2 => { // left
|
||||||
|
x = 0;
|
||||||
|
y = rng.float(f32) * screen_h;
|
||||||
|
},
|
||||||
|
3 => { // right
|
||||||
|
x = screen_w;
|
||||||
|
y = rng.float(f32) * screen_h;
|
||||||
|
},
|
||||||
|
else => unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
// velocity toward center
|
||||||
|
const dx = CENTER_X - x;
|
||||||
|
const dy = CENTER_Y - y;
|
||||||
|
const dist = @sqrt(dx * dx + dy * dy);
|
||||||
|
const vx = (dx / dist) * ENTITY_SPEED;
|
||||||
|
const vy = (dy / dist) * ENTITY_SPEED;
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.x = x,
|
||||||
|
.y = y,
|
||||||
|
.vx = vx,
|
||||||
|
.vy = vy,
|
||||||
|
.color = 0x00FFFF, // cyan
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// tests
|
||||||
|
|
||||||
|
test "Entity struct has correct size" {
|
||||||
|
// 5 fields: x, y, vx, vy (f32 = 4 bytes each) + color (u32 = 4 bytes)
|
||||||
|
// 20 bytes total as per plan
|
||||||
|
try std.testing.expectEqual(@as(usize, 20), @sizeOf(Entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Entity can be created with initial values" {
|
||||||
|
const entity = Entity{
|
||||||
|
.x = 100.0,
|
||||||
|
.y = 200.0,
|
||||||
|
.vx = 1.5,
|
||||||
|
.vy = -0.5,
|
||||||
|
.color = 0x00FFFF,
|
||||||
|
};
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(f32, 100.0), entity.x);
|
||||||
|
try std.testing.expectEqual(@as(f32, 200.0), entity.y);
|
||||||
|
try std.testing.expectEqual(@as(f32, 1.5), entity.vx);
|
||||||
|
try std.testing.expectEqual(@as(f32, -0.5), entity.vy);
|
||||||
|
try std.testing.expectEqual(@as(u32, 0x00FFFF), entity.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Entities init starts empty" {
|
||||||
|
const entities = Entities.init();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), entities.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Entities add increases count" {
|
||||||
|
var entities = Entities.init();
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
entities.add(&rng);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), entities.count);
|
||||||
|
|
||||||
|
entities.add(&rng);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), entities.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Entities remove decreases count" {
|
||||||
|
var entities = Entities.init();
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
// add 5
|
||||||
|
for (0..5) |_| entities.add(&rng);
|
||||||
|
try std.testing.expectEqual(@as(usize, 5), entities.count);
|
||||||
|
|
||||||
|
// remove 2
|
||||||
|
entities.remove(2);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), entities.count);
|
||||||
|
|
||||||
|
// remove more than count
|
||||||
|
entities.remove(100);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), entities.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Entities reset clears all" {
|
||||||
|
var entities = Entities.init();
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
for (0..10) |_| entities.add(&rng);
|
||||||
|
try std.testing.expectEqual(@as(usize, 10), entities.count);
|
||||||
|
|
||||||
|
entities.reset();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), entities.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "spawnAtEdge creates entity on screen edge" {
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
// spawn several and check they're on edges
|
||||||
|
for (0..20) |_| {
|
||||||
|
const entity = spawnAtEdge(&rng);
|
||||||
|
const on_left = entity.x == 0;
|
||||||
|
const on_right = entity.x == @as(f32, @floatFromInt(SCREEN_WIDTH));
|
||||||
|
const on_top = entity.y == 0;
|
||||||
|
const on_bottom = entity.y == @as(f32, @floatFromInt(SCREEN_HEIGHT));
|
||||||
|
|
||||||
|
try std.testing.expect(on_left or on_right or on_top or on_bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "spawnAtEdge velocity points toward center" {
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
for (0..20) |_| {
|
||||||
|
const entity = spawnAtEdge(&rng);
|
||||||
|
|
||||||
|
// after one step, should be closer to center
|
||||||
|
const dist_before = @sqrt((entity.x - CENTER_X) * (entity.x - CENTER_X) +
|
||||||
|
(entity.y - CENTER_Y) * (entity.y - CENTER_Y));
|
||||||
|
|
||||||
|
const new_x = entity.x + entity.vx;
|
||||||
|
const new_y = entity.y + entity.vy;
|
||||||
|
const dist_after = @sqrt((new_x - CENTER_X) * (new_x - CENTER_X) +
|
||||||
|
(new_y - CENTER_Y) * (new_y - CENTER_Y));
|
||||||
|
|
||||||
|
try std.testing.expect(dist_after < dist_before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "spawnAtEdge velocity has correct speed" {
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
const entity = spawnAtEdge(&rng);
|
||||||
|
const speed = @sqrt(entity.vx * entity.vx + entity.vy * entity.vy);
|
||||||
|
|
||||||
|
try std.testing.expectApproxEqAbs(ENTITY_SPEED, speed, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "update moves entities by velocity" {
|
||||||
|
var entities = Entities.init();
|
||||||
|
// manually place an entity
|
||||||
|
entities.items[0] = .{
|
||||||
|
.x = 100.0,
|
||||||
|
.y = 100.0,
|
||||||
|
.vx = 2.0,
|
||||||
|
.vy = 3.0,
|
||||||
|
.color = 0x00FFFF,
|
||||||
|
};
|
||||||
|
entities.count = 1;
|
||||||
|
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
update(&entities, &rng);
|
||||||
|
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f32, 102.0), entities.items[0].x, 0.001);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f32, 103.0), entities.items[0].y, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "update respawns entity at edge when reaching center" {
|
||||||
|
var entities = Entities.init();
|
||||||
|
// place entity very close to center (within threshold)
|
||||||
|
entities.items[0] = .{
|
||||||
|
.x = CENTER_X + 1.0,
|
||||||
|
.y = CENTER_Y + 1.0,
|
||||||
|
.vx = -1.0,
|
||||||
|
.vy = -1.0,
|
||||||
|
.color = 0x00FFFF,
|
||||||
|
};
|
||||||
|
entities.count = 1;
|
||||||
|
|
||||||
|
var prng = std.Random.DefaultPrng.init(12345);
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
// after update, entity moves to center and should respawn
|
||||||
|
update(&entities, &rng);
|
||||||
|
|
||||||
|
// should now be on an edge
|
||||||
|
const entity = entities.items[0];
|
||||||
|
const on_left = entity.x == 0;
|
||||||
|
const on_right = entity.x == @as(f32, @floatFromInt(SCREEN_WIDTH));
|
||||||
|
const on_top = entity.y == 0;
|
||||||
|
const on_bottom = entity.y == @as(f32, @floatFromInt(SCREEN_HEIGHT));
|
||||||
|
|
||||||
|
try std.testing.expect(on_left or on_right or on_top or on_bottom);
|
||||||
|
}
|
||||||
130
src/sandbox_main.zig
Normal file
130
src/sandbox_main.zig
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// sandbox stress test entry point
|
||||||
|
// measures entity count ceiling on weak hardware
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const rl = @import("raylib");
|
||||||
|
const sandbox = @import("sandbox.zig");
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = sandbox.SCREEN_WIDTH;
|
||||||
|
const SCREEN_HEIGHT = sandbox.SCREEN_HEIGHT;
|
||||||
|
|
||||||
|
// colors
|
||||||
|
const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 };
|
||||||
|
const CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 };
|
||||||
|
|
||||||
|
pub fn main() !void {
|
||||||
|
rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lofivor sandbox");
|
||||||
|
defer rl.closeWindow();
|
||||||
|
rl.setTargetFPS(60);
|
||||||
|
|
||||||
|
var entities = sandbox.Entities.init();
|
||||||
|
var prng = std.Random.DefaultPrng.init(@intCast(std.time.timestamp()));
|
||||||
|
var rng = prng.random();
|
||||||
|
|
||||||
|
var paused = false;
|
||||||
|
|
||||||
|
// timing
|
||||||
|
var update_time_us: i64 = 0;
|
||||||
|
var render_time_us: i64 = 0;
|
||||||
|
|
||||||
|
while (!rl.windowShouldClose()) {
|
||||||
|
// controls
|
||||||
|
handleInput(&entities, &rng, &paused);
|
||||||
|
|
||||||
|
// update
|
||||||
|
if (!paused) {
|
||||||
|
const update_start = std.time.microTimestamp();
|
||||||
|
sandbox.update(&entities, &rng);
|
||||||
|
update_time_us = std.time.microTimestamp() - update_start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// render
|
||||||
|
const render_start = std.time.microTimestamp();
|
||||||
|
|
||||||
|
rl.beginDrawing();
|
||||||
|
rl.clearBackground(BG_COLOR);
|
||||||
|
|
||||||
|
// draw entities as filled circles
|
||||||
|
for (entities.items[0..entities.count]) |entity| {
|
||||||
|
rl.drawCircle(
|
||||||
|
@intFromFloat(entity.x),
|
||||||
|
@intFromFloat(entity.y),
|
||||||
|
4,
|
||||||
|
CYAN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// metrics overlay
|
||||||
|
drawMetrics(&entities, update_time_us, render_time_us, paused);
|
||||||
|
|
||||||
|
rl.endDrawing();
|
||||||
|
|
||||||
|
render_time_us = std.time.microTimestamp() - render_start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleInput(entities: *sandbox.Entities, rng: *std.Random, paused: *bool) void {
|
||||||
|
const shift = rl.isKeyDown(.left_shift) or rl.isKeyDown(.right_shift);
|
||||||
|
const add_count: usize = if (shift) 1000 else 100;
|
||||||
|
|
||||||
|
// add entities: = or +
|
||||||
|
if (rl.isKeyPressed(.equal) or rl.isKeyPressed(.kp_add)) {
|
||||||
|
for (0..add_count) |_| {
|
||||||
|
entities.add(rng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove entities: - or _
|
||||||
|
if (rl.isKeyPressed(.minus) or rl.isKeyPressed(.kp_subtract)) {
|
||||||
|
entities.remove(add_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset: r
|
||||||
|
if (rl.isKeyPressed(.r)) {
|
||||||
|
entities.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause: space
|
||||||
|
if (rl.isKeyPressed(.space)) {
|
||||||
|
paused.* = !paused.*;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: i64, paused: bool) void {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
var y: i32 = 10;
|
||||||
|
const line_height: i32 = 20;
|
||||||
|
|
||||||
|
// entity count
|
||||||
|
const count_text = std.fmt.bufPrintZ(&buf, "entities: {d}", .{entities.count}) catch "?";
|
||||||
|
rl.drawText(count_text, 10, y, 16, rl.Color.white);
|
||||||
|
y += line_height;
|
||||||
|
|
||||||
|
// frame time
|
||||||
|
const frame_ms = rl.getFrameTime() * 1000.0;
|
||||||
|
const frame_text = std.fmt.bufPrintZ(&buf, "frame: {d:.1}ms", .{frame_ms}) catch "?";
|
||||||
|
rl.drawText(frame_text, 10, y, 16, rl.Color.white);
|
||||||
|
y += line_height;
|
||||||
|
|
||||||
|
// update time
|
||||||
|
const update_ms = @as(f32, @floatFromInt(update_us)) / 1000.0;
|
||||||
|
const update_text = std.fmt.bufPrintZ(&buf, "update: {d:.1}ms", .{update_ms}) catch "?";
|
||||||
|
rl.drawText(update_text, 10, y, 16, rl.Color.white);
|
||||||
|
y += line_height;
|
||||||
|
|
||||||
|
// render time
|
||||||
|
const render_ms = @as(f32, @floatFromInt(render_us)) / 1000.0;
|
||||||
|
const render_text = std.fmt.bufPrintZ(&buf, "render: {d:.1}ms", .{render_ms}) catch "?";
|
||||||
|
rl.drawText(render_text, 10, y, 16, rl.Color.white);
|
||||||
|
y += line_height;
|
||||||
|
|
||||||
|
// paused indicator
|
||||||
|
if (paused) {
|
||||||
|
y += line_height;
|
||||||
|
rl.drawText("PAUSED", 10, y, 16, rl.Color.yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// controls help (bottom)
|
||||||
|
const help_y: i32 = @intCast(SCREEN_HEIGHT - 30);
|
||||||
|
rl.drawText("+/-: 100 shift+/-: 1000 space: pause r: reset", 10, help_y, 14, rl.Color.gray);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue