flail-survivor/src/main-scene.zig
2024-02-18 23:12:38 +02:00

657 lines
19 KiB
Zig

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 = 13;
const playerSize = 20;
const virtualWidth = 350;
const virtualHeight = 350;
const safeRadius = 500;
const spawnAreaWidth = 400;
const arenaRadius = safeRadius + spawnAreaWidth;
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 updatePlayer(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);
}
if (player.position.length() > arenaRadius) {
const push_strength = std.math.pow(f32, player.position.length() - arenaRadius + 10, 2);
const push_direction = player.position.normalize().neg();
player.acceleration = player.acceleration.add(push_direction.scale(push_strength));
}
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 updateEnemies(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 and player.alive()) {
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 player.alive() 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 = 20;
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 = 10 });
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 = 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,
"You died!",
content.center_top().add(.{ .x = 0, .y = 30 }),
30,
rl.BLACK
);
try allocDrawText(
allocator,
"Kills: {d}", .{self.killCount},
@intFromFloat(content.left() + 10),
@intFromFloat(content.top() + 60),
30,
rl.BLACK
);
if (self.ui.button("Restart?", content.left(), content.bottom() - 70, content.width, 30)) {
self.reset();
}
if (self.ui.button("Exit?", content.left(), content.bottom() - 30, 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 = 7;
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.DrawLineEx(
player.position,
player.position.add((rl.Vector2{ .x = playerSize + healthWidth, .y = 0 }).rotate(percent * 2 * rl.PI)),
5,
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
);
}
fn rgba(r: u8, g: u8, b: u8, a: u8) rl.Color {
return rl.Color{ .r = r, .g = g, .b = b, .a = a };
}
fn rgb(r: u8, g: u8, b: u8) rl.Color {
return rgba(r, g, b, 255);
}
fn drawWorld(self: *Self) void {
rl.rlSetLineWidth(1);
var enemies = &self.enemies;
var rope = &self.rope;
const deathBallPosition = rope.nodePositions.get(rope.nodePositions.len-1);
rl.ClearBackground(rl.BLACK);
rl.DrawCircle(0, 0, arenaRadius, rl.BROWN);
rl.DrawCircle(0, 0, 5, rl.GOLD);
rl.DrawCircleLines(0, 0, safeRadius, rl.WHITE);
rl.DrawCircleLines(0, 0, safeRadius + spawnAreaWidth, rl.WHITE);
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
);
}
rl.rlDrawRenderBatchActive();
rl.rlSetLineWidth(3);
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),
rgb(106, 7, 17)
);
}
rl.rlDrawRenderBatchActive();
rl.rlSetLineWidth(1);
for (rope.nodePositions.slice()) |node| {
rl.DrawCircle(
@intFromFloat(node.x),
@intFromFloat(node.y),
5,
rgb(194, 12, 30)
);
}
rl.DrawCircle(
@intFromFloat(deathBallPosition.x),
@intFromFloat(deathBallPosition.y),
deathBallSize,
rgb(239, 48, 67)
);
}
pub fn tick(self: *Self) !void {
const dt = rl.GetFrameTime();
if (self.player.input.isPausePressed() and self.player.alive()) {
self.togglePaused();
}
if (!self.paused) {
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);
self.updatePlayer();
try self.updateEnemies();
if (self.player.alive()) {
self.timePassed += rl.GetFrameTime();
}
}
self.pixel_effect.begin();
rl.BeginMode2D(self.camera);
self.drawWorld();
rl.EndMode2D();
self.pixel_effect.end();
self.pixel_effect.beginTransform();
try self.tickUI();
self.pixel_effect.endTransform();
}