sokoban-v2/src/app.zig
2026-09-15 00:10:38 +03:00

423 lines
12 KiB
Zig

const std = @import("std");
const log = std.log.scoped(.app);
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Math = @import("math");
const Vec2 = Math.Vec2;
const STBImage = @import("stb_image");
const STBTrueType = @import("stb_truetype");
const Tiled = @import("tiled");
const Platform = @import("platform/root.zig");
const Gfx = Platform.Gfx;
const Audio = Platform.Audio;
const ImGUI = Platform.ImGUI;
const Rect = Platform.Rect;
const Color = Platform.Color;
const CanvasScaler = @import("./canvas_scaler.zig");
const App = @This();
const Direction = enum {
up,
down,
left,
right
};
const PlayerSprites = struct {
up: [3]Gfx.Sprite.Id,
down: [3]Gfx.Sprite.Id,
left: [3]Gfx.Sprite.Id,
right: [3]Gfx.Sprite.Id,
pub fn getDirection(self: *PlayerSprites, dir: Direction) []Gfx.Sprite.Id {
return switch (dir) {
.up => &self.up,
.down => &self.down,
.left => &self.left,
.right => &self.right,
};
}
};
const Tilemap = struct {
tiles: []Tile,
width: usize,
height: usize,
const Tile = struct {
ground_sprite: ?Gfx.Sprite.Id,
wall_sprite: ?Gfx.Sprite.Id,
const empty = Tile{
.ground_sprite = null,
.wall_sprite = null
};
};
pub fn init(gpa: Allocator, width: usize, height: usize) !Tilemap {
const tiles = try gpa.alloc(Tile, width * height);
@memset(tiles, .empty);
return Tilemap{
.width = width,
.height = height,
.tiles = tiles
};
}
pub fn deinit(self: *Tilemap, gpa: Allocator) void {
gpa.free(self.tiles);
}
pub fn get(self: *Tilemap, x: usize, y: usize) *Tile {
assert(x < self.width);
assert(y < self.height);
return &self.tiles[y * self.width + x];
}
};
const Entity = struct {
type: Type,
pos_x: i32 = 0,
pos_y: i32 = 0,
sprite: ?Gfx.Sprite.Id = null,
player_direction: Direction = .down,
player_frame_index: u32 = 0,
const Type = enum {
player,
box
};
const SlotMap = Platform.SlotMapType(u8, u16, Entity);
const Id = SlotMap.Id;
};
roboto_font: Gfx.Font,
tilemap: Tilemap,
entities: Entity.SlotMap,
tilesheet: Tilesheet,
player_sprites: PlayerSprites,
animation_timer: Platform.Nanoseconds,
footstep_buffer: Audio.BufferId,
footstep_sound: Audio.SoundId = .nil,
sfx_bus: Audio.BusId,
const Tilesheet = struct {
image: Gfx.ImageData,
sprites: []Gfx.Sprite.Id,
tile_size: Vec2,
width: u32,
height: u32,
pub fn init(gpa: Allocator, image: Gfx.ImageData, tile_width: u32, tile_height: u32) !Tilesheet {
const width = image.width / tile_width;
const height = image.height / tile_height;
const sprites = try gpa.alloc(Gfx.Sprite.Id, width * height);
errdefer gpa.free(sprites);
@memset(sprites, .nil);
return Tilesheet{
.sprites = sprites,
.image = image,
.width = width,
.height = height,
.tile_size = .initFromInt(u32, tile_width, tile_height)
};
}
pub fn deinit(self: Tilesheet) void {
for (self.sprites) |sprite| {
Gfx.deinitSprite(sprite);
}
}
pub fn get(self: Tilesheet, x: usize, y: usize) Gfx.Sprite.Id {
if (x >= self.width or y >= self.height) {
log.warn("Attempt to get tile which is out of bounds", .{});
return .nil;
}
const sprite_index = y * self.width + x;
if (self.sprites[sprite_index] == Gfx.Sprite.Id.nil) {
const sprite = Gfx.initSprite(.{ .padding = 1 });
Gfx.setSprite(sprite, .{
.image = self.image,
.rect = Rect.init(@floatFromInt(x), @floatFromInt(y), 1, 1).multiply(self.tile_size)
});
self.sprites[sprite_index] = sprite;
}
return self.sprites[sprite_index];
}
};
pub fn init(self: *App, plt: Platform.Init) !?u8 {
const roboto_font = try Gfx.Font.init(plt.gpa, .{
.spritesheet = Gfx.initSpritesheet(.{ .format = .r8 }),
.ttf_data = plt.assets.readFile("roboto-font/Roboto-Regular.ttf")
});
const tilesheet_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet.png"));
const tilesheet = try Tilesheet.init(
plt.arena,
Gfx.ImageData{
.width = tilesheet_png.width,
.height = tilesheet_png.height,
.pixels = .{ .rgba8 = tilesheet_png.rgba8_pixels }
},
64, 64,
);
var player_sprites: PlayerSprites = undefined;
inline for (.{
.{
.dir = .up,
.positions = [_]Vec2{ .init(3, 4), .init(4, 4), .init(5, 4) }
},
.{
.dir = .down,
.positions = [_]Vec2{ .init(0, 4), .init(1, 4), .init(2, 4) }
},
.{
.dir = .right,
.positions = [_]Vec2{ .init(0, 6), .init(1, 6), .init(2, 6) },
},
.{
.dir = .left,
.positions = [_]Vec2{ .init(3, 6), .init(4, 6), .init(5, 6) }
}
}) |entry| {
var sprite_ids = player_sprites.getDirection(entry.dir);
inline for (0.., entry.positions) |i, pos| {
sprite_ids[i] = tilesheet.get(@intFromFloat(pos.x), @intFromFloat(pos.y));
}
}
const footstep_sound = Audio.addBuffer(.{
.vorbis = plt.assets.readFile("impact-sounds/footstep_concrete_000.ogg")
});
self.* = App{
.player_sprites = player_sprites,
.roboto_font = roboto_font,
.animation_timer = 0,
.tilesheet = tilesheet,
.footstep_buffer = footstep_sound,
.sfx_bus = Audio.addBus(.{ .label = "sfx" }),
.tilemap = undefined,
.entities = try .initCapacity(plt.gpa, 256)
};
var player_pos_x: i32 = 0;
var player_pos_y: i32 = 0;
{
var scratch = std.heap.ArenaAllocator.init(plt.gpa);
defer scratch.deinit();
var buffers = Tiled.xml.Lexer.Buffers.init(plt.gpa);
defer buffers.deinit();
const tilemap = try Tiled.Tilemap.initFromBuffer(plt.gpa, &scratch, &buffers, plt.assets.readFile("tiled/map.tmx"));
defer tilemap.deinit();
var tilesets: Tiled.Tileset.List = .empty;
defer tilesets.deinit(plt.gpa);
try tilesets.add(plt.gpa, "tileset.tsx", try Tiled.Tileset.initFromBuffer(plt.gpa, &scratch, &buffers, plt.assets.readFile("tiled/tileset.tsx")));
const bounds = tilemap.getTileBounds();
self.tilemap = try Tilemap.init(plt.gpa, bounds.width(), bounds.height());
const ground = tilemap.getLayer("Ground").?;
const walls = tilemap.getLayer("Walls").?;
for (0..bounds.height()) |oy| {
for (0..bounds.width()) |ox| {
const x = bounds.min_x + ox;
const y = bounds.min_y + oy;
const tile = self.tilemap.get(ox, oy);
if (tilemap.getTile(ground, tilesets, x, y)) |tiled_tile| {
const pos = tiled_tile.getPosition();
tile.ground_sprite = tilesheet.get(pos.x, pos.y);
}
if (tilemap.getTile(walls, tilesets, x, y)) |tiled_tile| {
const pos = tiled_tile.getPosition();
tile.wall_sprite = tilesheet.get(pos.x, pos.y);
}
}
}
const markers = &tilemap.getLayer("Markers").?.variant.object;
const spawn = markers.getByName("spawn").?;
player_pos_x = @divFloor(@as(i32, @intFromFloat(spawn.shape.point.x)), @as(i32, @intCast(tilemap.tile_width)));
player_pos_x -= @intCast(bounds.min_x);
player_pos_y = @divFloor(@as(i32, @intFromFloat(spawn.shape.point.y)),@as(i32, @intCast(tilemap.tile_height)));
player_pos_y -= @intCast(bounds.min_y);
const boxes = tilemap.getLayer("Boxes").?;
for (0..bounds.height()) |oy| {
for (0..bounds.width()) |ox| {
const x = bounds.min_x + ox;
const y = bounds.min_y + oy;
if (tilemap.getTile(boxes, tilesets, x, y)) |tiled_tile| {
const pos = tiled_tile.getPosition();
_ = try self.spawnBox(@intCast(ox), @intCast(oy), tilesheet.get(pos.x, pos.y));
}
}
}
}
const player_id = try self.spawnPlayer();
const player = self.entities.getAssumeExists(player_id);
player.pos_x = player_pos_x;
player.pos_y = player_pos_y;
return null;
}
fn spawnPlayer(self: *App) !Entity.Id {
return try self.entities.insert(Entity{
.type = .player,
});
}
fn spawnBox(self: *App, x: i32, y: i32, sprite: Gfx.Sprite.Id) !Entity.Id {
return try self.entities.insert(Entity{
.type = .box,
.pos_x = x,
.pos_y = y,
.sprite = sprite
});
}
pub fn frame(self: *App, plt: Platform.Frame) !void {
const input = plt.input;
var dir_y: i32 = 0;
var dir_x: i32 = 0;
if (input.isKeyPressedOrRepeat(.S)) {
dir_y += 1;
}
if (input.isKeyPressedOrRepeat(.W)) {
dir_y -= 1;
}
if (input.isKeyPressedOrRepeat(.D)) {
dir_x += 1;
}
if (input.isKeyPressedOrRepeat(.A)) {
dir_x -= 1;
}
if (dir_x != 0) {
dir_y = 0;
}
const clear_color = Color.rgb(40, 40, 40);
Gfx.setClearColor(clear_color);
const tile_size = Vec2.init(48, 48);
var canvas = CanvasScaler{
.size = Vec2.initFromInt(usize, self.tilemap.width, self.tilemap.height).multiply(tile_size)
};
canvas.push();
defer canvas.pop(clear_color);
for (0..self.tilemap.height) |y| {
for (0..self.tilemap.width) |x| {
const tile = self.tilemap.get(x, y);
if (tile.ground_sprite) |sprite| {
Gfx.drawSprite(sprite, Vec2.initFromInt(usize, x, y).multiply(tile_size), tile_size, .white);
}
if (tile.wall_sprite) |sprite| {
Gfx.drawSprite(sprite, Vec2.initFromInt(usize, x, y).multiply(tile_size), tile_size, .white);
}
}
}
var entity_iter = self.entities.iterator();
while (entity_iter.next()) |entity_id| {
const entity = self.entities.getAssumeExists(entity_id);
if (entity.type == .player) {
entity.pos_x += dir_x;
entity.pos_y += dir_y;
var new_direction = entity.player_direction;
if (dir_x < 0) {
new_direction = .left;
} else if (dir_x > 0) {
new_direction = .right;
} else if (dir_y > 0) {
new_direction = .down;
} else if (dir_y < 0) {
new_direction = .up;
}
if (entity.player_direction != new_direction) {
entity.player_frame_index = 0;
entity.player_direction = new_direction;
}
const frames = self.player_sprites.getDirection(entity.player_direction);
if (dir_x != 0 or dir_y != 0) {
self.animation_timer += plt.input.delta_time;
const animatin_time = std.time.ns_per_s / 5;
while (self.animation_timer >= animatin_time) {
entity.player_frame_index = (entity.player_frame_index + 1) % @as(u32, @intCast(frames.len));
self.animation_timer -= animatin_time;
}
} else {
self.animation_timer = 0;
entity.player_frame_index = 0;
}
if (dir_x != 0 or dir_y != 0) {
self.footstep_sound = Audio.play(.{ .buffer = self.footstep_buffer, .bus = self.sfx_bus });
}
entity.sprite = frames[entity.player_frame_index];
}
if (entity.sprite) |sprite| {
Gfx.drawSprite(
sprite,
Vec2.initFromInt(i32, entity.pos_x, entity.pos_y).multiply(tile_size),
tile_size,
.white
);
}
}
// self.roboto_font.drawText(.{
// .text = try std.fmt.allocPrint(plt.frame, "Hello {:.0}!", .{plt.time()}),
// .pos = .init(0, 0),
// .height = 32,
// });
}
pub fn deinit(self: *App, plt: Platform.Deinit) void {
self.roboto_font.deinit(plt.gpa);
Gfx.deinitSpritesheet(self.roboto_font.spritesheet);
self.tilemap.deinit(plt.gpa);
self.entities.slots.deinit(plt.gpa);
}