sokoban-v2/src/app.zig

607 lines
18 KiB
Zig

const std = @import("std");
const log = std.log.scoped(.app);
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
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 Vec2 = Platform.Vec2;
const Audio = Platform.Audio;
const ImGUI = Platform.ImGUI;
const Rect = Platform.Rect;
const Color = Platform.Color;
const CanvasScaler = @import("./canvas_scaler.zig");
const Font = @import("./font.zig");
const Tilesheet = @import("./tilesheet.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 Tile = struct {
ground_sprite: ?Gfx.Sprite.Id,
entities_by_layer: std.EnumArray(Entity.Layer, Entity.Id),
const empty = Tile{
.ground_sprite = null,
.entities_by_layer = .initFill(.nil)
};
};
const Tilemap = struct {
tiles: []Tile,
width: usize,
height: usize,
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 clone(self: Tilemap, gpa: Allocator) !Tilemap {
const tiles = try gpa.dupe(Tile, self.tiles);
errdefer gpa.free(tiles);
return Tilemap{
.tiles = tiles,
.width = self.width,
.height = self.height,
};
}
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,
layer: Layer,
pos_x: i32 = -1,
pos_y: i32 = -1,
visual_pos: Vec2 = .init(0, 0),
sprite: ?Gfx.Sprite.Id = null,
player_direction: Direction = .down,
player_frame_index: u32 = 0,
const Type = enum {
player,
box,
goal_marker,
wall
};
const Layer = enum {
ground,
surface,
};
const SlotMap = Platform.SlotMapType(u16, u8, Entity);
const Id = SlotMap.Id;
};
const World = struct {
tilemap: Tilemap,
entities: Entity.SlotMap,
pub fn clone(self: World, gpa: Allocator) !World {
var tilemap = try self.tilemap.clone(gpa);
errdefer tilemap.deinit(gpa);
var entity_slots = try self.entities.slots.clone(gpa);
errdefer entity_slots.deinit(gpa);
var entities = self.entities;
entities.slots = entity_slots;
return World{
.tilemap = tilemap,
.entities = entities
};
}
pub fn deinit(self: *World, gpa: Allocator) void {
self.tilemap.deinit(gpa);
self.entities.deinit(gpa);
}
fn spawnEntity(self: *World, opts: Entity) Entity.Id {
var e = opts;
e.pos_x = -1;
e.pos_y = -1;
const entity_id = self.entities.insert(e) catch |err| {
log.err("Failed to spawn entity: {}", .{err});
return .nil;
};
if (opts.pos_x != -1 or opts.pos_y != -1) {
assert(opts.pos_x != -1 and opts.pos_y != -1);
if (!self.setEntityPosition(entity_id, opts.pos_x, opts.pos_y)) {
log.err("Entity position: {}, {}", .{opts.pos_x, opts.pos_y});
@panic("Failed to set initial entity position");
}
}
return entity_id;
}
fn getTile(self: *World, x: i32, y: i32) ?*Tile {
const width: i32 = @intCast(self.tilemap.width);
const height: i32 = @intCast(self.tilemap.height);
if (0 <= x and x < width and 0 <= y and y < height) {
return &self.tilemap.tiles[@intCast(x + y * width)];
}
return null;
}
fn setEntityPosition(self: *World, id: Entity.Id, x: i32, y: i32) bool {
const e = self.entities.get(id) orelse return false;
const destination_tile = self.getTile(x, y) orelse return false;
if (self.entities.exists(destination_tile.entities_by_layer.get(e.layer))) {
// Cant't overwrite entity
return false;
}
destination_tile.entities_by_layer.set(e.layer, id);
if (self.getTile(e.pos_x, e.pos_y)) |tile| {
tile.entities_by_layer.set(e.layer, .nil);
}
e.pos_x = x;
e.pos_y = y;
return true;
}
fn moveEntity(self: *World, id: Entity.Id, dx: i32, dy: i32) bool {
const e = self.entities.get(id) orelse return false;
const next_x = e.pos_x+dx;
const next_y = e.pos_y+dy;
const destination_tile = self.getTile(next_x, next_y) orelse return false;
if (e.type == .player or e.type == .box) {
const destination_entity_id = destination_tile.entities_by_layer.get(e.layer);
if (self.entities.get(destination_entity_id)) |destination_entity| {
if (destination_entity.type == .box) {
if (!self.moveEntity(destination_entity_id, dx, dy)) {
return false;
}
}
}
}
return self.setEntityPosition(id, next_x, next_y);
}
};
const Level = struct {
number: i32,
world: World,
};
world: World,
current_level: u32 = 0,
levels: std.ArrayList(Level),
roboto_font: Font,
tilesheet: Tilesheet,
player_sprites: PlayerSprites,
animation_timer: Platform.Nanoseconds,
footstep_buffer: Audio.BufferId,
footstep_sound: Audio.SoundId = .nil,
sfx_bus: Audio.BusId,
pub fn init(self: *App, plt: Platform.Init) !?u8 {
const roboto_font = try Font.init(plt.gpa, .{
.spritesheet = Gfx.initSpritesheet(.{ .format = .r8 }),
.ttf_data = plt.assets.readFile("roboto-font/Roboto-Regular.ttf")
});
const tilesheet1_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet.png"));
const tilesheet2_png = try STBImage.load(plt.assets.readFile("sokoban/sokoban_tilesheet@2.png"));
assert(tilesheet2_png.width == tilesheet1_png.width*2);
assert(tilesheet2_png.height == tilesheet1_png.height*2);
var levels = [_][*]u8{ tilesheet2_png.rgba8_pixels, tilesheet1_png.rgba8_pixels };
const tilesheet = try Tilesheet.init(
plt.arena,
.init(.{
.width = tilesheet2_png.width,
.height = tilesheet2_png.height,
.format = .rgba8,
.levels = &levels,
}),
.init(128, 128),
.{
.padding = 1,
.spritesheet = Gfx.initSpritesheet(.{ .mipmaps = levels.len })
}
);
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" }),
.world = undefined,
.levels = .empty,
};
self.levels = try self.loadLevels(plt.gpa, plt.assets);
self.world = try self.levels.items[0].world.clone(plt.gpa);
return null;
}
fn loadLevels(self: *App, gpa: Allocator, assets: *Platform.Assets) !std.ArrayList(Level) {
var xml_parser = try Tiled.xml.Parser.initCapacity(gpa, .{});
defer xml_parser.deinit(gpa);
const tilemap = try Tiled.Tilemap.initFromXml(
gpa,
try xml_parser.parse(assets.readFile("tiled/map.tmx")),
.root
);
defer tilemap.deinit();
var tilesets: Tiled.Tileset.List = .empty;
defer tilesets.deinit(gpa);
try tilesets.add(gpa, "tileset.tsx", try Tiled.Tileset.initFromXml(
gpa,
try xml_parser.parse(assets.readFile("tiled/tileset.tsx")),
.root
));
var levels: std.ArrayList(Level) = .empty;
const level_bounds = tilemap.getLayer("Level bounds").?;
for (level_bounds.variant.object.items) |obj| {
if (obj.shape != .rectangle) {
continue;
}
const level_index = obj.properties.getInt("level") orelse {
log.warn("Level bounds doesn't have index", .{});
continue;
};
var rect = obj.shape.rectangle;
rect.x /= @floatFromInt(tilemap.tile_width);
rect.y /= @floatFromInt(tilemap.tile_height);
rect.width /= @floatFromInt(tilemap.tile_width);
rect.height /= @floatFromInt(tilemap.tile_height);
var level_bound = Tiled.Tilemap.Bounds{
.min_x = @intFromFloat(@floor(rect.x)),
.min_y = @intFromFloat(@floor(rect.y)),
.max_x = @intFromFloat(@ceil(rect.x + rect.width)),
.max_y = @intFromFloat(@ceil(rect.y + rect.height)),
};
level_bound = tilemap.trimBoundsByEmptyTiles(level_bound);
var level = try self.loadLevel(gpa, tilemap, tilesets, level_bound);
level.number = level_index;
try levels.append(gpa, level);
}
const Context = struct {
fn lessThan(_: @This(), lhs: Level, rhs: Level) bool {
return lhs.number < rhs.number;
}
};
std.mem.sort(Level, levels.items, Context{}, Context.lessThan);
return levels;
}
fn loadLevel(
self: *App,
gpa: Allocator,
tilemap: Tiled.Tilemap,
tilesets: Tiled.Tileset.List,
bounds: Tiled.Tilemap.Bounds
) !Level {
var level = Level{
.number = 0,
.world = .{
.tilemap = try .init(gpa, bounds.width(), bounds.height()),
.entities = try .initCapacity(gpa, 256)
}
};
const ground = tilemap.getLayer("Ground").?;
const foreground = tilemap.getLayer("Foreground").?;
for (0..bounds.height()) |oy| {
for (0..bounds.width()) |ox| {
const x = bounds.min_x + @as(i32, @intCast(ox));
const y = bounds.min_y + @as(i32, @intCast(oy));
const tile = level.world.tilemap.get(ox, oy);
if (tilemap.getTile(ground, tilesets, x, y)) |tiled_tile| {
const pos = tiled_tile.getPosition();
tile.ground_sprite = self.tilesheet.get(pos.x, pos.y);
}
if (tilemap.getTile(foreground, tilesets, x, y)) |tiled_tile| {
const pos = tiled_tile.getPosition();
const props = tiled_tile.getProperties();
const props_type = props.getString("type") orelse "";
var entity_type: ?Entity.Type = null;
var layer: Entity.Layer = .surface;
if (std.mem.eql(u8, props_type, "wall")) {
entity_type = .wall;
} else if (std.mem.eql(u8, props_type, "box")) {
entity_type = .box;
} else if (std.mem.eql(u8, props_type, "goal_marker")) {
entity_type = .goal_marker;
layer = .ground;
} else {
log.warn("Unknown tile type: '{s}'", .{props_type});
}
if (entity_type != null) {
_ = level.world.spawnEntity(Entity{
.type = entity_type.?,
.layer = layer,
.pos_x = @intCast(ox),
.pos_y = @intCast(oy),
.sprite = self.tilesheet.get(pos.x, pos.y)
});
}
}
}
}
for (tilemap.getLayer("Markers").?.variant.object.items) |obj| {
if (obj.shape != .point) {
continue;
}
if (!std.mem.eql(u8, obj.name, "spawn")) {
continue;
}
var position = obj.shape.point;
position.x /= @floatFromInt(tilemap.tile_width);
position.y /= @floatFromInt(tilemap.tile_height);
const pos_x: i32 = @intFromFloat(position.x);
const pos_y: i32 = @intFromFloat(position.y);
if (bounds.isInside(pos_x, pos_y)) {
_ = level.world.spawnEntity(Entity{
.type = .player,
.layer = .surface,
.pos_x = pos_x - bounds.min_x,
.pos_y = pos_y - bounds.min_y
});
}
}
return level;
}
fn updateWorld(world: *World) void {
_ = world; // autofix
}
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);
Gfx.setDefaultSampler(.linear);
const tile_size = Vec2.init(48, 48);
var canvas = CanvasScaler{
.size = Vec2.initFromInt(usize, self.world.tilemap.width, self.world.tilemap.height).multiply(tile_size)
};
canvas.push();
defer canvas.pop(clear_color);
var goal_count: u32 = 0;
var box_at_goal_count: u32 = 0;
{
var entity_iter = self.world.entities.iterator();
while (entity_iter.next()) |entity_id| {
const entity = self.world.entities.getAssumeExists(entity_id);
if (entity.type == .player) {
if ((dir_x != 0 or dir_y != 0) and self.world.moveEntity(entity_id, dir_x, 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;
}
self.footstep_sound = Audio.play(.{ .buffer = self.footstep_buffer, .bus = self.sfx_bus });
}
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;
}
entity.sprite = frames[entity.player_frame_index];
} else if (entity.type == .goal_marker) {
goal_count += 1;
const tile = self.world.getTile(entity.pos_x, entity.pos_y).?;
if (self.world.entities.get(tile.entities_by_layer.get(.surface))) |above_entity| {
if (above_entity.type == .box) {
box_at_goal_count += 1;
}
}
}
entity.visual_pos = .initFromInt(i32, entity.pos_x, entity.pos_y);
}
}
for (0..self.world.tilemap.height) |y| {
for (0..self.world.tilemap.width) |x| {
const tile = self.world.tilemap.get(x, y);
if (tile.ground_sprite) |sprite| {
Gfx.drawSprite(sprite, Vec2.initFromInt(usize, x, y).multiply(tile_size), tile_size, .white);
}
}
}
inline for (.{ Entity.Layer.ground, Entity.Layer.surface }) |layer| {
var entity_iter = self.world.entities.iterator();
while (entity_iter.next()) |entity_id| {
const entity = self.world.entities.getAssumeExists(entity_id);
if (entity.layer != layer) {
continue;
}
if (entity.sprite) |sprite| {
Gfx.drawSprite(
sprite,
entity.visual_pos.multiply(tile_size),
tile_size,
.white
);
}
}
}
if (goal_count > 0 and goal_count == box_at_goal_count and self.current_level+1 < self.levels.items.len) {
self.current_level += 1;
self.world.deinit(plt.gpa);
self.world = try self.levels.items[self.current_level].world.clone(plt.gpa);
}
}
pub fn deinit(self: *App, plt: Platform.Deinit) void {
self.roboto_font.deinit(plt.gpa);
Gfx.deinitSpritesheet(self.roboto_font.spritesheet);
self.world.deinit(plt.gpa);
for (self.levels.items) |*level| {
level.world.deinit(plt.gpa);
}
self.levels.deinit(plt.gpa);
}