620 lines
18 KiB
Zig
620 lines
18 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 = 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();
|
|
}
|