const rl = @import("raylib"); const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const PlayerInput = @import("player-input.zig"); const UI = @import("./ui.zig"); const PixelPerfect = @import("./pixel-perfect.zig"); const UIBox = @import("./ui-box.zig"); const friction = 0.99; const walkForce = 4000; const maxSpeed = 200; const handDistance = 50; const deathBallSize: f32 = 10; const playerSize = 20; const virtualWidth = 300; const virtualHeight = 300; const enemyFriction = friction; const Enemy = struct { position: rl.Vector2, health: f32, color: rl.Color, size: f32, acceleration: f32, maxSpeed: f32, invincibility: f32 = 0, stunned: f32 = 0, velocity: rl.Vector2 = .{ .x = 0, .y = 0 }, fn initWeak(position: rl.Vector2) Enemy { return Enemy{ .position = position, .health = 1.0, .color = rl.YELLOW, .size = 10.0, .acceleration = 5000.0, .maxSpeed = 150, }; } fn initNormal(position: rl.Vector2) Enemy { return Enemy{ .position = position, .health = 2.0, .color = rl.ORANGE, .size = 20.0, .acceleration = 4000, .maxSpeed = 100, }; } fn initStrong(position: rl.Vector2) Enemy { return Enemy{ .position = position, .health = 5.0, .color = rl.GOLD, .size = 50.0, .acceleration = 3000, .maxSpeed = 50, }; } fn vulnerable(self: Enemy) bool { return self.invincibility == 0; } fn alive(self: Enemy) bool { return self.health > 0; } }; const Player = struct { position: rl.Vector2, velocity: rl.Vector2 = rl.Vector2.zero(), acceleration: rl.Vector2 = rl.Vector2.zero(), handDirection: rl.Vector2 = rl.Vector2{ .x = 1, .y = 0 }, input: PlayerInput = PlayerInput.init(), health: u32 = 3, maxHealth: u32 = 3, dead: bool = false, invincibility: f32 = 0, fn getHandPosition(self: *const Player) rl.Vector2 { return self.position.add(self.handDirection.scale(handDistance)); } fn alive(self: Player) bool { return self.health > 0; } fn vulnerable(self: Player) bool { return self.invincibility == 0; } }; const Rope = struct { const maxNodes = 64; const NodeArray = std.BoundedArray(rl.Vector2, maxNodes); nodePositions: NodeArray, nodeVelocities: NodeArray, segmentLength: f32, springForce: f32 = 10, leftoverTime: f32 = 0, friction: f32 = 0.0005, fn init(from: rl.Vector2, to: rl.Vector2, nodeCount: u32) Rope { assert(nodeCount <= maxNodes); var nodePositions = NodeArray.init(nodeCount) catch unreachable; const diff = to.sub(from); const segmentLength = diff.length() / @as(f32, @floatFromInt(nodeCount)); const segmentStep = diff.normalize().scale(segmentLength); nodePositions.set(0, from); for (1..nodeCount) |i| { const nextNode = nodePositions.get(i-1).add(segmentStep); nodePositions.set(i, nextNode); } var nodeVelocities = NodeArray.init(nodeCount) catch unreachable; for (nodeVelocities.slice()) |*vel| { vel.* = rl.Vector2.zero(); } return Rope{ .nodePositions = nodePositions, .nodeVelocities = nodeVelocities, .segmentLength = segmentLength }; } fn update(self: *Rope, dt: f32) void { const timestep = 1.0/(60.0 * 100); self.leftoverTime += dt; while (self.leftoverTime >= timestep) : (self.leftoverTime -= timestep) { self.update_step(timestep); } } fn update_step(self: *Rope, dt: f32) void { var offsets = NodeArray.init(self.nodePositions.len) catch unreachable; for (offsets.slice()) |*offset| { offset.* = rl.Vector2.zero(); } var accelerations = NodeArray.init(self.nodePositions.len) catch unreachable; for (accelerations.slice()) |*acc| { acc.* = rl.Vector2.zero(); } const node_count = self.nodePositions.len; for (1..node_count) |i| { const node1: rl.Vector2 = self.nodePositions.get(i-1); const node2: rl.Vector2 = self.nodePositions.get(i); const node_diff = node2.sub(node1); const node_dir = node_diff.normalize(); const rope_force = node_diff.length() - self.segmentLength; const d = node_dir.scale(rope_force); offsets.set(i-1, offsets.get(i-1).add(d.scale(0.5))); offsets.set(i , offsets.get(i ).add(d.scale(-0.5))); accelerations.set(i-1, accelerations.get(i-1).add(d)); accelerations.set(i , accelerations.get(i ).add(d.scale(-1))); } for (1..node_count) |i| { const pos = &self.nodePositions.slice()[i]; const vel = &self.nodeVelocities.slice()[i]; const acc = accelerations.get(i); const offset = offsets.get(i); vel.* = vel.add(acc.scale(dt)); vel.* = vel.scale(1 - self.friction); const amplifier = @as(f32, @floatFromInt(i-1))/@as(f32, @floatFromInt(node_count-2)); pos.* = pos.add(vel.scale(dt*10000 + amplifier*5)); pos.* = pos.add(offset.scale(0.01)); } } fn lastNodePosition(self: *Rope) rl.Vector2 { return self.nodePositions.get(self.nodePositions.len-1); } fn lastNodeVelocity(self: *Rope) rl.Vector2 { return self.nodeVelocities.get(self.nodeVelocities.len-1); } }; allocator: Allocator, prng: std.rand.DefaultPrng, enemyTimer: f32 = 0, spawnedEnemiesCount: u32 = 0, killCount: u32 = 0, timePassed: f32 = 0, enemies: std.ArrayList(Enemy), player: Player, rope: Rope, ui: UI, should_close: bool = false, paused: bool = false, pixel_effect: PixelPerfect, camera: rl.Camera2D, const Self = @This(); pub fn init(allocator: Allocator) Self { var playerPosition = rl.Vector2{ .x = 0, .y = 0 }; var ropeSize: f32 = 200; var handPosition = playerPosition.add(rl.Vector2{ .x = handDistance, .y = 0}); var player = Player{ .position = playerPosition }; player.input.activate(); return Self { .allocator = allocator, .prng = std.rand.DefaultPrng.init(@bitCast(std.time.timestamp())), .enemies = std.ArrayList(Enemy).init(allocator), .player = player, .rope = Rope.init(handPosition, handPosition.add(.{ .x = ropeSize, .y = 0.002 }), 10), .camera = .{ .target = playerPosition }, .ui = UI.init(), .pixel_effect = PixelPerfect.init(virtualWidth, virtualHeight) }; } pub fn reset(self: *Self) void { self.deinit(); self.* = Self.init(self.allocator); } pub fn deinit(self: *Self) void { self.player.input.deactivate(); self.enemies.deinit(); self.pixel_effect.deinit(); } fn tickPlayer(self: *Self) void { const dt = rl.GetFrameTime(); var player = &self.player; var rope = &self.rope; player.invincibility = @max(0, player.invincibility - dt); player.acceleration = rl.Vector2.zero(); if (player.alive()) { player.handDirection = player.input.getHandPosition(); const moveDir = player.input.getWalkDirection(); player.acceleration = moveDir; player.acceleration = rl.Vector2Scale(player.acceleration, walkForce); } player.velocity = rl.Vector2Add(player.velocity, rl.Vector2Scale(player.acceleration, dt)); player.velocity = rl.Vector2ClampValue(player.velocity, 0, maxSpeed); player.velocity = rl.Vector2Scale(player.velocity, std.math.pow(f32, (1 - friction), dt)); player.position = rl.Vector2Add(player.position, rl.Vector2Scale(player.velocity, dt)); const handPosition = player.getHandPosition(); const rope_root_node: rl.Vector2 = rope.nodePositions.get(0); rope.nodePositions.set(0, rope_root_node.lerp(handPosition, 0.25)); rope.update(dt); } fn tickEnemies(self: *Self) !void { var rng = self.prng.random(); const dt = rl.GetFrameTime(); var enemies = &self.enemies; var player = &self.player; var rope = &self.rope; self.enemyTimer -= dt; if (self.enemyTimer <= 0) { self.enemyTimer = 0.5 + rng.float(f32) * 2; self.spawnedEnemiesCount += 1; const enemyPosition = rl.Vector2.randomOnUnitCircle(rng).scale(400); try enemies.append(Enemy.initStrong(enemyPosition)); } const deathBallPosition = rope.lastNodePosition(); for (enemies.items) |*enemy| { const toPlayer = player.position.sub(enemy.position); if (player.vulnerable() and toPlayer.length() <= playerSize + enemy.size) { self.damagePlayer(); } if (enemy.vulnerable() and rl.Vector2Distance(enemy.position, deathBallPosition) < enemy.size + deathBallSize) { self.damageEnemy(enemy); } var acceleration = rl.Vector2.zero(); if (enemy.stunned == 0) { const directionToPlayer = toPlayer.normalize(); acceleration = acceleration.add(directionToPlayer); } var enemyPushForce = rl.Vector2.zero(); for (enemies.items) |*otherEnemy| { if (otherEnemy == enemy) continue; const difference = enemy.position.sub(otherEnemy.position); const enemyDistance = difference.length(); if (otherEnemy.size + enemy.size > enemyDistance*1.2) { enemyPushForce = enemyPushForce.add(difference.normalize()); } } acceleration = acceleration.add(enemyPushForce); enemy.velocity = enemy.velocity.add(acceleration.scale(enemy.acceleration * dt)); enemy.velocity = rl.Vector2ClampValue(enemy.velocity, 0, enemy.maxSpeed); enemy.velocity = rl.Vector2Scale(enemy.velocity, std.math.pow(f32, (1 - enemyFriction), dt)); enemy.position = enemy.position.add(enemy.velocity.scale(dt)); enemy.invincibility = @max(0, enemy.invincibility - dt); enemy.stunned = @max(0, enemy.stunned - dt); } { // Remove dead enemies var i: usize = 0; while (i < enemies.items.len) { const enemy = &enemies.items[i]; if (enemy.health == 0) { self.killCount += 1; _ = enemies.swapRemove(i); continue; } i += 1; } } } fn damagePlayer(self: *Self) void { const player = &self.player; if (player.alive()) { player.health -= 1; player.invincibility = 0.8; } if (!player.alive()) { player.input.deactivate(); } } fn damageEnemy(self: *Self, enemy: *Enemy) void { const deathBallVelocity = self.rope.lastNodeVelocity(); if (enemy.alive()) { enemy.health -= 1; } enemy.invincibility = 0.5; enemy.stunned = 0.75; enemy.velocity = deathBallVelocity.scale(20000); } fn getScreenSize() rl.Vector2 { return rl.Vector2{ .x = @floatFromInt(rl.GetScreenWidth()), .y = @floatFromInt(rl.GetScreenHeight()) }; } fn allocDrawText( allocator: Allocator, comptime fmt: []const u8, fmt_args: anytype, x: i32, y: i32, font_size: i32, color: rl.Color, ) !void { const text = try std.fmt.allocPrintZ(allocator, fmt, fmt_args); defer allocator.free(text); rl.DrawText(text, x, y, font_size, color); } fn tickUI(self: *Self) !void { self.ui.mouse_position = self.pixel_effect.getMousePosition(); self.ui.mouse_delta = self.pixel_effect.getMouseDelta(); self.ui.begin(); defer self.ui.end(); var allocator = self.allocator; const screen_box = UIBox.init(0 ,0, virtualWidth, virtualHeight); const font = rl.GetFontDefault(); try allocDrawText(allocator, "{d}", .{self.spawnedEnemiesCount}, 10, 10, 12, rl.RED); try allocDrawText(allocator, "{d}", .{self.killCount}, 10, 30, 12, rl.GREEN); const minutes_text = try std.fmt.allocPrint(allocator, "{d:.0}", .{self.timePassed/60}); defer allocator.free(minutes_text); const seconds_text = try std.fmt.allocPrint(allocator, "{d:.3}", .{@mod(self.timePassed, 60)}); defer allocator.free(seconds_text); const time_passed_text = try std.mem.concatWithSentinel(allocator, u8, &.{ minutes_text, ":", seconds_text }, 0); defer allocator.free(time_passed_text); const minutes_text_z = try std.mem.concatWithSentinel(allocator, u8, &.{ minutes_text }, 0); defer allocator.free(minutes_text_z); const font_size = 12; const time_passed_width: f32 = @floatFromInt(rl.MeasureText(minutes_text_z, font_size) + rl.MeasureText(":000", font_size)); const time_passed_pos = screen_box.center_top().add(.{ .x = -time_passed_width/2, .y = 30 }); rl.DrawTextEx( font, time_passed_text, time_passed_pos, font_size, font_size/10, rl.GREEN ); if (!self.player.alive()) { const modal_size = rl.Vector2{ .x = 200, .y = 400 }; const modal = screen_box.box( screen_box.center() - modal_size.x/2, screen_box.middle() - modal_size.y/2, modal_size.x, modal_size.y ); const content = modal.margin(10); rl.DrawRectangleRec(modal.rect(), rl.RAYWHITE); UI.drawTextAligned( font, "You died!", content.center_top().add(.{ .x = 0, .y = 30 }), 30, rl.BLACK ); if (self.ui.button("Restart?", content.left(), content.top() + 70, content.width, 30)) { self.reset(); } if (self.ui.button("Exit?", content.left(), content.top() + 120, content.width, 30)) { self.should_close = true; } } else if (self.paused) { const modal_size = rl.Vector2{ .x = 200, .y = 200 }; const modal = screen_box.box( screen_box.center() - modal_size.x/2, screen_box.middle() - modal_size.y/2, modal_size.x, modal_size.y ); const content = modal.margin(10); rl.DrawRectangleRec(modal.rect(), rl.RAYWHITE); UI.drawTextAligned( font, "Paused", content.center_top().add(.{ .x = 0, .y = 30 }), 30, rl.BLACK ); if (self.ui.button("Continue?", content.left(), content.top() + 70, content.width, 30)) { self.togglePaused(); } if (self.ui.button("Exit?", content.left(), content.top() + 120, content.width, 30)) { self.should_close = true; } } } fn togglePaused(self: *Self) void { self.paused = !self.paused; if (self.paused) { self.player.input.deactivate(); } else { self.player.input.activate(); } } fn drawPlayer(self: *Self) void { var player = &self.player; const handPosition = player.getHandPosition(); const healthWidth = 5; const healthPercent = @as(f32, @floatFromInt(player.health)) / @as(f32, @floatFromInt(player.maxHealth)); var healthColor = rl.GREEN; if (player.invincibility > 0 and @rem(player.invincibility, 0.2) < 0.1) { healthColor = rl.RED; } rl.DrawCircleSector( player.position, playerSize + healthWidth, 0, 360 * healthPercent, 0, healthColor, ); for (0..(player.health+1)) |i| { const percent = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(player.maxHealth)); rl.DrawLineV( player.position, player.position.add((rl.Vector2{ .x = playerSize + healthWidth, .y = 0 }).rotate(percent * 2 * rl.PI)), rl.BLACK ); } rl.DrawCircle( @intFromFloat(player.position.x), @intFromFloat(player.position.y), playerSize, rl.RAYWHITE ); rl.DrawCircle( @intFromFloat(handPosition.x), @intFromFloat(handPosition.y), 5, rl.RAYWHITE ); } pub fn tick(self: *Self) !void { const dt = rl.GetFrameTime(); self.camera.offset.x = virtualWidth/2; self.camera.offset.y = virtualHeight/2; self.camera.zoom = 0.4; self.camera.target = rl.Vector2Lerp(self.camera.target, self.player.position, 10 * dt); var enemies = &self.enemies; var rope = &self.rope; if (self.player.input.isPausePressed()) { self.togglePaused(); } if (!self.paused) { self.tickPlayer(); try self.tickEnemies(); if (self.player.alive()) { self.timePassed += rl.GetFrameTime(); } } const deathBallPosition = rope.nodePositions.get(rope.nodePositions.len-1); self.pixel_effect.begin(); rl.ClearBackground(rl.BROWN); rl.BeginMode2D(self.camera); { rl.DrawCircle(0, 0, 5, rl.GOLD); self.drawPlayer(); for (enemies.items) |*enemy| { var color = enemy.color; if (enemy.invincibility > 0 and @rem(enemy.invincibility, 0.2) < 0.1) { color = rl.LIGHTGRAY; } rl.DrawCircle( @intFromFloat(enemy.position.x), @intFromFloat(enemy.position.y), enemy.size, color ); } const rope_color = rl.PURPLE; for (0..(rope.nodePositions.len-1)) |i| { var node1: rl.Vector2 = rope.nodePositions.get(i); var node2: rl.Vector2 = rope.nodePositions.get(i+1); rl.DrawLine( @intFromFloat(node1.x), @intFromFloat(node1.y), @intFromFloat(node2.x), @intFromFloat(node2.y), rope_color ); } for (rope.nodePositions.slice()) |node| { rl.DrawCircle( @intFromFloat(node.x), @intFromFloat(node.y), 5, rope_color ); } rl.DrawCircle( @intFromFloat(deathBallPosition.x), @intFromFloat(deathBallPosition.y), deathBallSize, rl.RED ); } rl.EndMode2D(); self.pixel_effect.end(); self.pixel_effect.beginTransform(); try self.tickUI(); self.pixel_effect.endTransform(); }