44 Commits
Author SHA1 Message Date
codegirl007 be9fc24cd9 refactor gold rewards and tower cost; fix wave test leak 2026-06-29 00:31:34 -07:00
codegirl007 6f30cd5ff2 add color to tower archetype, remove kind 2026-06-29 00:11:26 -07:00
codegirl007 f2a24a37b8 refactor enemy to remove redundancy 2026-06-28 23:36:37 -07:00
codegirl007 2c4527ae8b move control constants to constants.odin 2026-06-28 00:46:28 -07:00
codegirl007 d29b4f07ea clean up get_archtype for towers and build_controls_layout function, replacing with constants 2026-06-28 00:42:53 -07:00
codegirl007 3026aeadc2 remove function, make tile_colors a table 2026-06-28 00:27:09 -07:00
codegirl007 c717f92b6e remove incorrect constant 2026-06-28 00:26:43 -07:00
codegirl007 8585a18894 create constants from world.odin 2026-06-28 00:14:39 -07:00
codegirl007 9317e83fd5 clean up overlay.odin 2026-06-28 00:08:43 -07:00
codegirl007 e166076e11 split tower and tower placement 2026-06-27 23:58:18 -07:00
codegirl007 3e1b809199 add preliminary tests for wave 2026-06-27 23:46:43 -07:00
codegirl007 24389bbc63 fix test post-revert 2026-06-27 23:33:23 -07:00
codegirl007 2a99d7ecf3 Revert world object refactors 2026-06-27 23:22:45 -07:00
codegirl007 6bdb096e1b test for math2d and wave 2026-06-27 23:11:18 -07:00
codegirl007 e7806dcd66 refactor base health 2026-06-27 00:38:22 -07:00
codegirl007 28165ed399 refactor map 2026-06-27 00:32:42 -07:00
codegirl007 bfc39201e1 refactor world wave 2026-06-26 20:53:31 -07:00
codegirl007 4d9c52c56a Refactor world gold and combat 2026-06-26 20:41:39 -07:00
codegirl007 e75dd82a76 fix typo in distance that was causing range to be further than intended, increase range of archer 2026-06-26 19:46:05 -07:00
codegirl007 781e8653e7 move path length check to outside the loop 2026-06-26 18:54:08 -07:00
codegirl007 7e69113d37 fix update enemy bug on short paths 2026-06-26 18:38:06 -07:00
codegirl007 1e74f5b054 fix missing max_upgrade 2026-06-26 18:11:13 -07:00
codegirl007 d57f8a126c fix memory leak in waves by clearing out the recipes before creating a new spawner 2026-06-26 18:08:11 -07:00
codegirl007 f2f8f1fe1e fix placement bug 2026-06-26 18:01:06 -07:00
codegirl007 f14864182c splash damage 2026-06-26 17:43:58 -07:00
codegirl007 4124f39632 ballistic projectiles for cannons 2026-06-26 17:11:25 -07:00
codegirl007 4d267c7f5d tower ugprades 2026-06-25 20:28:55 -07:00
codegirl007 d6ee5abb01 wave recipes + tanks 2026-06-24 20:28:02 -07:00
codegirl007 4bb3e710c1 Add different enemy types (Runner) 2026-06-23 21:44:50 -07:00
codegirl007 daa5f75c95 preview towers before placement 2026-06-23 11:32:24 -07:00
codegirl007 e660ce611b add ice towers - no projectiles, no button 2026-06-23 10:14:11 -07:00
codegirl007 0815530717 add cannon - no projectiles, no control button 2026-06-23 00:39:18 -07:00
codegirl007 fcfd1e95d8 support full screen gameplay 2026-06-21 01:27:45 -07:00
codegirl007 ff341cb834 remove render_hud call because that doesn't exist anymore 2026-06-21 00:32:40 -07:00
codegirl007 a968ad98fd move waves and enemy count to overlay 2026-06-20 22:26:21 -07:00
codegirl007 0729384898 add end screens 2026-06-20 22:05:11 -07:00
codegirl007 195b94076b create combat banner 2026-06-20 21:12:52 -07:00
codegirl007 1a18c42b9e clean up button creation + center text 2026-06-19 02:21:49 -07:00
codegirl007 433ac4152b first go at a control panel, not fancy 2026-06-19 01:49:04 -07:00
codegirl007 2d8cc5d1d7 Adding a README for completeness 2026-06-18 02:47:04 -07:00
codegirl007 54b10f000b add a start wave button 2026-06-18 01:31:06 -07:00
codegirl007 d44e9501f5 move health to overlay 2026-06-18 01:19:00 -07:00
codegirl007 75c29a4d5e draw overlay + move gold to overlay in pure raylib 2026-06-18 01:09:17 -07:00
codegirl007 c8d191e7c7 Remove Clay and associated changes 2026-06-18 00:56:44 -07:00
21 changed files with 1051 additions and 231 deletions
+14
View File
@@ -0,0 +1,14 @@
## Game
This is a very simple game.
## Objective
- Explore Nystrom's Game Programming patterns when appropriate.
- Learn Odin
- Enjoy the love of hand writing code (cuz I love it)
## Goals
- Build a playable single player game
- Build a multiplayer aspect
- Keep things as simple as possible
+16 -12
View File
@@ -3,27 +3,31 @@ package game
import rl "vendor:raylib"
run :: proc() {
rl.InitWindow(SCREEN_W, SCREEN_H, "Tower Defense")
defer rl.CloseWindow()
rl.SetTargetFPS(60)
init_display()
defer {
shutdown_display()
rl.CloseWindow()
}
world := init_world()
defer {
delete(world.path)
delete(world.towers)
delete(world.events)
delete(world.spawner.recipe)
}
for !rl.WindowShouldClose() {
dt := rl.GetFrameTime()
update_display()
update_world(&world, dt)
rl.BeginDrawing()
rl.ClearBackground(rl.Color{34, 139, 34, 255})
begin_game_draw()
render_world(&world)
rl.EndDrawing()
render_overlay(&world)
render_controls(&world)
end_game_draw()
}
delete(world.path)
delete(world.towers)
delete(world.events)
}
+15 -6
View File
@@ -6,6 +6,7 @@ Command_Kind :: enum {
None,
Place_Tower,
Start_Wave,
Upgrade_Tower,
}
Command :: struct {
@@ -15,18 +16,24 @@ Command :: struct {
}
gather_commands :: proc(world: ^World) -> Command {
ui_cmd := poll_controls_command(world)
if ui_cmd.kind != .None do return ui_cmd
if rl.IsKeyPressed(.ONE) do world.selected_tower = .Archer
if rl.IsKeyPressed(.TWO) do world.selected_tower = .Cannon
if rl.IsKeyPressed(.THREE) do world.selected_tower = .Ice
if world.phase == .Build && rl.IsKeyPressed(.N) {
return Command{kind = .Start_Wave}
}
if world.phase == .Build && rl.IsMouseButtonPressed(.LEFT) {
mouse := rl.GetMousePosition()
if rl.IsMouseButtonPressed(.RIGHT) {
mouse := game_mouse()
gx, gy := screen_to_grid(mouse.x, mouse.y)
return Command{kind = .Place_Tower, gx = gx, gy = gy}
return Command{kind = .Upgrade_Tower, gx = gx, gy = gy}
}
if rl.IsMouseButtonPressed(.LEFT) {
mouse := rl.GetMousePosition()
if world.phase == .Build && rl.IsMouseButtonPressed(.LEFT) {
mouse := game_mouse()
gx, gy := screen_to_grid(mouse.x, mouse.y)
return Command{kind = .Place_Tower, gx = gx, gy = gy}
}
@@ -38,9 +45,11 @@ execute_command :: proc(world: ^World, cmd: Command) {
switch cmd.kind {
case .None:
case .Place_Tower:
try_place_tower(world, cmd.gx, cmd.gy, .Archer)
try_place_tower(world, cmd.gx, cmd.gy, world.selected_tower)
case .Start_Wave:
start_wave(world)
case .Upgrade_Tower:
try_upgrade_tower(world, cmd.gx, cmd.gy)
}
}
+54 -2
View File
@@ -1,5 +1,7 @@
package game
import rl "vendor:raylib"
MAP_W :: 30 // map width in tiles
MAP_H :: 20 // map height in tiles
TILE_SIZE :: 32 // size of tiles in pixels
@@ -7,13 +9,63 @@ TILE_SIZE :: 32 // size of tiles in pixels
SCREEN_W :: MAP_W * TILE_SIZE
SCREEN_H :: MAP_H * TILE_SIZE
DEFAULT_WINDOW_W :: 1280
DEFAULT_WINDOW_H :: 720
MAX_ENEMIES :: 64
MAX_WAVES :: 5
MAX_TOWERS :: 32
TOWER_COST :: 50
MAX_PROJECTILES :: 64
PROJECTILE_SPEED :: 280 // pixels per second
PROJECTILE_HIT_RADIUS :: 10
CANNON_PROJECTILES_SPEED :: 320.0
OVERLAY_BAR_H :: 28
OVERLAY_PADDING :: 6
OVERLAY_BAR_COLOR :: rl.Color{20, 24, 20, 200}
OVERLAY_GOLD_COLOR :: rl.Color{255, 215, 80, 255}
OVERLAY_TEXT_COLOR :: rl.Color{240, 240, 230, 255}
OVERLAY_FONT_SIZE :: 18
CONTROL_BAR_H :: 40
CONTROL_BAR_BG :: rl.Color{30, 55, 30, 220}
CONTROL_BUTTON_COLOR :: rl.Color{60, 120, 60, 255}
CONTROL_BUTTON_HOVER :: rl.Color{60, 150, 60, 255}
CONTROLS_SHOP_IDLE :: rl.Color{45, 75, 45, 255}
CONTROL_SHOP_SELECTED :: rl.Color{70, 110, 70, 255}
CONTROLS_SHOP_HOVER :: rl.Color{90, 130, 90, 255}
ICE_SLOW_DURATION :: 2.5
ICE_SLOW_FACTOR :: 0.5
ENEMY_LABEL_COLOR :: rl.Color{255, 200, 200, 255}
// starting values for the game
STARTING_BASE_HEALTH :: 20
STARTING_GOLD :: 100
//controls constants
BOTTOM_BAR_Y :: SCREEN_H - CONTROL_BAR_H
ARCHER_BUTTON_W :: 120
START_WAVE_BUTTON_W :: 110
CONTROLS_BUTTON_GAP :: 12
CONTROLS_BUTTON_H :: 24
CONTROLS_BUTTON_Y :: BOTTOM_BAR_Y + 8
CONTROLS_BUTTON_X :: (SCREEN_W - (ARCHER_BUTTON_W + CONTROLS_BUTTON_GAP + START_WAVE_BUTTON_W)) / 2
ARCHER_BUTTON_RECT :: rl.Rectangle {
f32(CONTROLS_BUTTON_X),
f32(CONTROLS_BUTTON_Y),
ARCHER_BUTTON_W,
CONTROLS_BUTTON_H,
}
START_WAVE_BUTTON_RECT :: rl.Rectangle {
f32(CONTROLS_BUTTON_X + ARCHER_BUTTON_W + CONTROLS_BUTTON_GAP),
f32(CONTROLS_BUTTON_Y),
START_WAVE_BUTTON_W,
CONTROLS_BUTTON_H,
}
+76
View File
@@ -0,0 +1,76 @@
package game
import "core:fmt"
import rl "vendor:raylib"
poll_controls_command :: proc(world: ^World) -> Command {
if world.phase != .Build do return Command{kind = .None}
mouse := game_mouse()
if rl.IsMouseButtonPressed(.LEFT) {
if rl.CheckCollisionPointRec(mouse, ARCHER_BUTTON_RECT) {
world.selected_tower = .Archer
}
if rl.CheckCollisionPointRec(mouse, START_WAVE_BUTTON_RECT) {
return Command{kind = .Start_Wave}
}
}
return Command{kind = .None}
}
draw_archer_button :: proc(world: ^World, archer: rl.Rectangle) {
arch := TOWER_ARCHETYPES[.Archer]
mouse := rl.GetMousePosition()
arch_bg := CONTROLS_SHOP_IDLE
if world.selected_tower == .Archer do arch_bg = CONTROL_SHOP_SELECTED
if rl.CheckCollisionPointRec(mouse, archer) do arch_bg = CONTROLS_SHOP_HOVER
label := fmt.ctprintf("Archer - %dg", arch.cost)
draw_button(archer, label, arch_bg, 14, OVERLAY_TEXT_COLOR)
}
draw_start_wave_button :: proc(start_wave_rect: rl.Rectangle, mouse: rl.Vector2) {
bg := CONTROL_BUTTON_COLOR
if rl.CheckCollisionPointRec(mouse, start_wave_rect) do bg = CONTROL_BUTTON_HOVER
draw_button(start_wave_rect, "Start Wave", bg, 16, OVERLAY_TEXT_COLOR)
}
draw_build_controls :: proc(world: ^World) {
if world.phase != .Build do return
mouse := game_mouse()
rl.DrawRectangle(0, BOTTOM_BAR_Y, SCREEN_W, CONTROL_BAR_H, CONTROL_BAR_BG)
draw_archer_button(world, ARCHER_BUTTON_RECT)
draw_start_wave_button(START_WAVE_BUTTON_RECT, mouse)
}
render_controls :: proc(world: ^World) {
switch world.phase {
case .Build:
draw_build_controls(world)
case .Combat:
draw_combat_banner()
case .Game_Over, .Victory:
}
}
draw_combat_banner :: proc() {
rl.DrawRectangle(0, BOTTOM_BAR_Y, SCREEN_W, CONTROL_BAR_H, rl.Color{120, 40, 40, 200})
text: cstring = "Combat!"
font: i32 = 16
tw := rl.MeasureText(text, font)
rl.DrawText(
text,
(SCREEN_W - tw) / 2,
BOTTOM_BAR_Y + (CONTROL_BAR_H - font) / 2,
font,
rl.Color{255, 220, 220, 255},
)
}
+62
View File
@@ -0,0 +1,62 @@
package game
import rl "vendor:raylib"
game_target: rl.RenderTexture2D
init_display :: proc() {
rl.InitWindow(DEFAULT_WINDOW_W, DEFAULT_WINDOW_H, "Tower Defense")
rl.SetWindowState({.WINDOW_RESIZABLE})
rl.SetTargetFPS(60)
game_target = rl.LoadRenderTexture(i32(SCREEN_W), i32(SCREEN_H))
}
shutdown_display :: proc() {
rl.UnloadRenderTexture(game_target)
}
update_display :: proc() {
if rl.IsKeyPressed(.F11) {
rl.ToggleFullscreen()
}
}
game_viewport_dest :: proc() -> rl.Rectangle {
sw := f32(rl.GetRenderWidth())
sh := f32(rl.GetRenderHeight())
scale := min(sw / f32(SCREEN_W), sh / f32(SCREEN_H))
dw := f32(SCREEN_W) * scale
dh := f32(SCREEN_H) * scale
return {x = (sw - dw) * 0.5, y = (sh - dh) * 0.5, width = dw, height = dh}
}
game_mouse :: proc() -> rl.Vector2 {
m := rl.GetMousePosition()
dest := game_viewport_dest()
if dest.width <= 0 || dest.height <= 0 {
return m
}
return {
(m.x - dest.x) / dest.width * f32(SCREEN_W),
(m.y - dest.y) / dest.height * f32(SCREEN_H),
}
}
begin_game_draw :: proc() {
rl.BeginTextureMode(game_target)
rl.ClearBackground(rl.Color{34, 139, 34, 255})
}
end_game_draw :: proc() {
rl.EndTextureMode()
rl.BeginDrawing()
rl.ClearBackground(rl.BLACK)
dest := game_viewport_dest()
source := rl.Rectangle{0, 0, f32(SCREEN_W), -f32(SCREEN_H)}
rl.DrawTexturePro(game_target.texture, source, dest, {}, 0, rl.WHITE)
rl.EndDrawing()
}
+29
View File
@@ -0,0 +1,29 @@
package game
import "core:fmt"
import rl "vendor:raylib"
draw_end_screen :: proc(world: ^World) {
title: cstring = "GAME OVER"
title_color := rl.RED
sub: cstring = "Press close to quit"
sub_color := OVERLAY_TEXT_COLOR
if world.phase == .Victory {
title = "VICTORY!"
title_color = rl.GREEN
sub = fmt.ctprintf("You survived %d waves!", MAX_WAVES)
}
rl.DrawRectangle(0, 0, SCREEN_W, SCREEN_H, rl.Color{0, 0, 0, 140})
title_font: i32 = 40
tw := rl.MeasureText(title, title_font)
rl.DrawText(title, (SCREEN_W - tw) / 2, SCREEN_H / 2 - 50, title_font, title_color)
sub_font: i32 = 18
sw := rl.MeasureText(sub, sub_font)
rl.DrawText(sub, (SCREEN_W - sw) / 2, SCREEN_H / 2 + 10, sub_font, sub_color)
}
+71 -13
View File
@@ -1,6 +1,7 @@
package game
import "core:fmt"
import "core:sys/posix"
import rl "vendor:raylib"
Enemy :: struct {
@@ -9,7 +10,55 @@ Enemy :: struct {
max_health: int,
active: bool,
path_index: int,
speed: f32,
base_speed: f32,
slow_timer: f32,
kind: Enemy_Kind,
}
Enemy_Kind :: enum {
Grunt,
Runner,
Tank,
}
Enemy_Def :: struct {
base_health: int,
health_per_wave: int,
base_speed: f32,
speed_per_wave: f32,
color: rl.Color,
draw_radius: f32,
gold_reward: int,
}
ENEMY_DEF: [Enemy_Kind]Enemy_Def = {
.Grunt = {
base_health = 15,
health_per_wave = 5,
base_speed = 55,
speed_per_wave = 5,
color = rl.MAROON,
draw_radius = 11,
gold_reward = 5,
},
.Runner = {
base_health = 10,
health_per_wave = 2,
base_speed = 95,
speed_per_wave = 3,
color = rl.ORANGE,
draw_radius = 11,
gold_reward = 5,
},
.Tank = {
base_health = 50,
health_per_wave = 12,
base_speed = 35,
speed_per_wave = 2,
color = rl.DARKGREEN,
draw_radius = 14,
gold_reward = 10,
},
}
acquire_enemy :: proc(world: ^World) -> ^Enemy {
@@ -23,35 +72,34 @@ acquire_enemy :: proc(world: ^World) -> ^Enemy {
return nil
}
spawn_enemy :: proc(world: ^World, health: int, speed: f32) {
spawn_enemy :: proc(world: ^World, kind: Enemy_Kind) {
if len(world.path) == 0 do return // don't spawn enemies if no paths exist
e := acquire_enemy(world) // grab a free slot
if e == nil do return // if there are no slots, skil spawning
def := ENEMY_DEF[kind]
health := def.base_health + world.wave * def.health_per_wave
speed := def.base_speed + f32(world.wave) * def.speed_per_wave
e^ = Enemy {
position = world.path[0],
health = health,
max_health = health,
speed = speed,
base_speed = speed,
slow_timer = 0,
path_index = 1,
kind = kind,
active = true,
}
}
spawn_group :: proc(world: ^World, count: int) {
for _ in 0 ..< count {
spawn_enemy(world, 20, 60)
}
}
update_enemies :: proc(world: ^World, dt: f32) {
if len(world.path) < 2 do return
for i in 0 ..< MAX_ENEMIES {
e := &world.enemies[i]
if !e.active do continue
if len(world.path) == 2 do return
if e.path_index >= len(world.path) {
world.base_health -= 1
e.active = false
@@ -59,7 +107,12 @@ update_enemies :: proc(world: ^World, dt: f32) {
}
target := world.path[e.path_index]
move_toward(&e.position, target, e.speed, dt)
move_speed := e.base_speed
if e.slow_timer > 0 {
move_speed *= ICE_SLOW_FACTOR
e.slow_timer -= dt
}
move_toward(&e.position, target, move_speed, dt)
dx := target.x - e.position.x
dy := target.y - e.position.y
@@ -82,7 +135,12 @@ render_enemies :: proc(world: ^World) {
for i in 0 ..< MAX_ENEMIES {
e := world.enemies[i]
if !e.active do continue
rl.DrawCircle(i32(e.position.x), i32(e.position.y), 11, rl.MAROON)
def := ENEMY_DEF[e.kind]
rl.DrawCircle(i32(e.position.x), i32(e.position.y), def.draw_radius, def.color)
if e.slow_timer > 0 {
rl.DrawCircleLines(i32(e.position.x), i32(e.position.y), 14, rl.SKYBLUE)
}
rl.DrawText(
fmt.ctprintf("%d", e.health),
i32(e.position.x) - 4,
-23
View File
@@ -1,23 +0,0 @@
package game
import "core:fmt"
import rl "vendor:raylib"
render_hud :: proc(world: ^World) {
rl.DrawText(fmt.ctprintf("Gold: %d", world.gold), 10, 10, 22, rl.BLACK) // Gold HUD
rl.DrawText(fmt.ctprintf("Base HP: %d", world.base_health), 10, 32, 22, rl.BLACK) // HP HUD
rl.DrawText(fmt.ctprintf("Enemies: %d", active_enemy_count(world)), 10, 54, 22, rl.BLACK)
rl.DrawText(fmt.ctprintf("Wave: %d / %d", world.wave, MAX_WAVES), 10, 76, 22, rl.BLACK)
switch world.phase {
case .Build:
rl.DrawText("Build - click towers, N = start wave", 10, 98, 22, rl.BLACK)
case .Combat:
rl.DrawText("Combat!", 10, 98, 22, rl.MAROON)
case .Game_Over:
rl.DrawText("GAME_OVER", 260, 280, 40, rl.RED)
case .Victory:
rl.DrawText("Victory!", 260, 280, 40, rl.GREEN)
}
}
+5 -12
View File
@@ -11,17 +11,10 @@ Map :: struct {
tiles: [MAP_H][MAP_W]Tile_Kind,
}
tile_color :: proc(kind: Tile_Kind) -> rl.Color {
switch kind {
case .Blocked:
return {35, 95, 35, 255}
case .Path:
return {130, 85, 45, 255}
case .Build:
return {50, 130, 50, 255}
}
return rl.MAGENTA
TILE_COLORS: [Tile_Kind]rl.Color = {
.Blocked = {35, 95, 35, 255},
.Path = {130, 85, 45, 255},
.Build = {50, 130, 50, 255},
}
init_map :: proc(world_map: ^Map) {
@@ -57,7 +50,7 @@ can_build_at :: proc(world_map: ^Map, gx, gy: int) -> bool {
render_map :: proc(world: ^World) {
for y in 0 ..< MAP_H {
for x in 0 ..< MAP_W {
c := tile_color(world.world_map.tiles[y][x])
c := TILE_COLORS[world.world_map.tiles[y][x]]
rl.DrawRectangle(i32(x * TILE_SIZE), i32(y * TILE_SIZE), TILE_SIZE, TILE_SIZE, c)
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ screen_to_grid :: proc(px, py: f32) -> (int, int) {
distance :: proc(a, b: Vec2) -> f32 {
dx := b.x - a.x
dy := b.y - a.y
return math.sqrt(dx * dx + dy + dy)
return math.sqrt(dx * dx + dy * dy)
}
move_toward :: proc(pos: ^Vec2, target: Vec2, speed, dt: f32) {
+62
View File
@@ -0,0 +1,62 @@
package game
import "core:math"
import "core:testing"
@(test)
test_distance :: proc(t: ^testing.T) {
a := Vec2 {
x = 3,
y = 2,
}
b := Vec2 {
x = 1,
y = 2,
}
d := distance(a, b)
testing.expect(t, math.abs(d - 2.0) < 0.0001)
}
@(test)
test_move_toward_already_close :: proc(t: ^testing.T) {
pos := Vec2{0.5, 0}
target := Vec2{0, 0}
move_toward(&pos, target, 10, 1.0)
testing.expect(t, math.abs(pos.x - 0.5) < 0.0001)
testing.expect(t, math.abs(pos.y - 0.0) < 0.0001)
}
@(test)
test_move_toward_reaches_target :: proc(t: ^testing.T) {
pos := Vec2{0, 0}
target := Vec2{3, 4}
move_toward(&pos, target, 100, 1.0)
testing.expect(t, math.abs(pos.x - 3) < 0.0001)
testing.expect(t, math.abs(pos.y - 4) < 0.0001)
}
@(test)
test_move_toward_partial :: proc(t: ^testing.T) {
pos := Vec2{0, 0}
target := Vec2{10, 0}
move_toward(&pos, target, 2, 1.0)
testing.expect(t, math.abs(pos.x - 2) < 0.0001)
testing.expect(t, math.abs(pos.y) < 0.0001)
}
@(test)
test_move_toward_diagonal :: proc(t: ^testing.T) {
pos := Vec2{0, 0}
target := Vec2{3, 4}
move_toward(&pos, target, 5, 0.5)
}
+33
View File
@@ -0,0 +1,33 @@
package game
import "core:fmt"
import rl "vendor:raylib"
render_overlay :: proc(world: ^World) {
rl.DrawRectangle(0, 0, SCREEN_W, OVERLAY_BAR_H, OVERLAY_BAR_COLOR)
y: i32 = (OVERLAY_BAR_H - OVERLAY_FONT_SIZE) / 2
x: i32 = OVERLAY_PADDING
gap: i32 : 16
gold := fmt.ctprintf("Gold: %d", world.gold)
rl.DrawText(gold, OVERLAY_PADDING, OVERLAY_PADDING, 18, OVERLAY_GOLD_COLOR)
x += rl.MeasureText(gold, OVERLAY_FONT_SIZE) + gap
hp := fmt.ctprintf("Health: %d", world.base_health)
rl.DrawText(hp, x, y, OVERLAY_FONT_SIZE, OVERLAY_TEXT_COLOR)
x += rl.MeasureText(hp, OVERLAY_FONT_SIZE) + gap
enemies := fmt.ctprintf("Enemies: %d", active_enemy_count(world))
rl.DrawText(enemies, x, y, OVERLAY_FONT_SIZE, ENEMY_LABEL_COLOR)
x += rl.MeasureText(enemies, OVERLAY_FONT_SIZE) + gap
waves := fmt.ctprintf("Waves: %d / %d", world.wave, MAX_WAVES)
rl.DrawText(waves, x, y, OVERLAY_FONT_SIZE, OVERLAY_TEXT_COLOR)
if world.phase == .Game_Over || world.phase == .Victory {
draw_end_screen(world)
}
}
+104 -24
View File
@@ -3,11 +3,19 @@ package game
import rl "vendor:raylib"
Projectile :: struct {
active: bool,
position: Vec2,
target_slot: int,
damage: int,
speed: f32,
active: bool,
position: Vec2,
target_slot: int,
damage: int,
speed: f32,
mode: Projectile_Mode,
direction: Vec2,
splash_radius: f32,
}
Projectile_Mode :: enum {
Homing, // chases target
Ballistic, // flies in a fixed direction
}
acquire_projectile :: proc(world: ^World) -> ^Projectile {
@@ -33,37 +41,87 @@ spawn_projectile :: proc(world: ^World, from: Vec2, target_slot: int, damage: in
p := acquire_projectile(world)
if p == nil do return
p.mode = .Homing
p^ = Projectile {
active = true,
position = from,
target_slot = target_slot,
damage = damage,
speed = PROJECTILE_SPEED,
active = true,
position = from,
target_slot = target_slot,
damage = damage,
speed = PROJECTILE_SPEED,
splash_radius = 0,
}
}
spawn_ballistic :: proc(world: ^World, from, dir: Vec2, damage: int, splash: f32) {
p := acquire_projectile(world)
if p == nil do return
p^ = Projectile {
active = true,
position = from,
direction = dir,
mode = .Ballistic,
damage = damage,
speed = CANNON_PROJECTILES_SPEED,
splash_radius = splash,
}
}
hit_enemy_at :: proc(world: ^World, pos: Vec2, damage: int) -> bool {
for i in 0 ..< MAX_ENEMIES {
e := &world.enemies[i]
if !e.active do continue
if distance(pos, e.position) <= PROJECTILE_HIT_RADIUS {
apply_tower_hit(world, e, damage)
return true
}
}
return false
}
update_projectiles :: proc(world: ^World, dt: f32) {
for i in 0 ..< MAX_PROJECTILES {
p := &world.projectiles[i]
if !p.active do continue
target, ok := enemy_still_on_map(world, p.target_slot)
if !ok {
p.active = false
continue
switch p.mode {
case .Homing:
update_homing_projectile(world, p, dt)
case .Ballistic:
update_ballistic_projectile(world, p, dt)
}
}
}
move_toward(&p.position, target.position, p.speed, dt)
update_homing_projectile :: proc(world: ^World, p: ^Projectile, dt: f32) {
target, ok := enemy_still_on_map(world, p.target_slot)
if !ok {
p.active = false
return
}
if distance(p.position, target.position) <= PROJECTILE_HIT_RADIUS {
target.health -= p.damage
p.active = false
move_toward(&p.position, target.position, p.speed, dt)
if target.health <= 0 {
target.active = false
push_event(world, .Enemy_Killed, gold_reward = 5)
}
}
if distance(p.position, target.position) <= PROJECTILE_HIT_RADIUS {
p.active = false
apply_tower_hit(world, target, p.damage)
}
}
update_ballistic_projectile :: proc(world: ^World, p: ^Projectile, dt: f32) {
step := p.speed * dt
p.position.x += p.direction.x * step
p.position.y += p.direction.y * step
if apply_splash_damage(world, p.position, p.damage, p.splash_radius) {
p.active = false
return
}
if p.position.x < 0 ||
p.position.y < 0 ||
p.position.x > MAP_W * TILE_SIZE ||
p.position.y > MAP_H * TILE_SIZE {
p.active = false
}
}
@@ -71,7 +129,29 @@ render_projectiles :: proc(world: ^World) {
for i in 0 ..< MAX_PROJECTILES {
p := world.projectiles[i]
if !p.active do continue
rl.DrawCircle(i32(p.position.x), i32(p.position.y), 4, rl.GOLD)
color := rl.GOLD
radius: f32 = 4
if p.mode == .Ballistic {
color = rl.GRAY
radius = 6
}
rl.DrawCircle(i32(p.position.x), i32(p.position.y), radius, color)
}
}
apply_splash_damage :: proc(world: ^World, center: Vec2, damage: int, radius: f32) -> bool {
if radius <= 0 do return hit_enemy_at(world, center, damage)
hit_any := false
for i in 0 ..< MAX_ENEMIES {
e := &world.enemies[i]
if !e.active do continue
if distance(center, e.position) <= radius {
apply_tower_hit(world, e, damage)
hit_any = true
}
}
return hit_any
}
+1 -1
View File
@@ -4,7 +4,7 @@ render_world :: proc(world: ^World) {
render_map(world)
render_enemies(world)
render_towers(world)
render_placement_preview(world)
render_projectiles(world)
render_hud(world)
}
+111 -102
View File
@@ -1,125 +1,91 @@
package game
import "core:fmt"
import rl "vendor:raylib"
Tower :: struct {
position: Vec2,
kind: Tower_Kind,
cooldown: f32,
anchor_gx: int,
anchor_gy: int,
upgrade_level: int,
position: Vec2,
kind: Tower_Kind,
cooldown: f32,
anchor_gx: int,
anchor_gy: int,
}
Tower_Kind :: enum {
Archer,
Cannon,
Ice,
}
Tower_Archetype :: struct {
kind: Tower_Kind,
range: f32,
damage: int,
fire_rate: f32,
cost: int,
footprint_w: int, // 1 column wide
footprint_h: int, // two rows tall
range: f32,
damage: int,
fire_rate: f32,
cost: int,
footprint_w: int, // 1 column wide
footprint_h: int, // two rows tall
max_upgrade: int,
upgrade_cost: int,
damage_per_level: int,
range_per_level: f32,
splash_radius: f32,
color: rl.Color,
}
TOWER_ARCHETYPES: [Tower_Kind]Tower_Archetype = {
.Archer = {
kind = .Archer,
range = 50,
range = 100,
damage = 12,
fire_rate = 0.45,
cost = TOWER_COST,
cost = 50,
footprint_w = 1,
footprint_h = 2,
max_upgrade = 1,
upgrade_cost = 40,
damage_per_level = 3,
range_per_level = 8,
splash_radius = 0,
color = rl.YELLOW,
},
.Cannon = {
range = 110,
damage = 35,
fire_rate = 1.2,
cost = 80,
footprint_w = 2,
footprint_h = 2,
max_upgrade = 1,
upgrade_cost = 40,
damage_per_level = 3,
range_per_level = 8,
splash_radius = 60,
color = rl.DARKGRAY,
},
.Ice = {
range = 85,
damage = 6,
fire_rate = 0.55,
cost = 65,
footprint_w = 1,
footprint_h = 1,
max_upgrade = 1,
upgrade_cost = 40,
damage_per_level = 3,
range_per_level = 8,
splash_radius = 0,
color = rl.DARKBLUE,
},
}
rects_overlap :: proc(
left_a, top_a, width_a, height_a: int,
left_b, top_b, width_b, height_b: int,
) -> bool {
return(
left_a < left_b + width_b &&
left_a + width_a > left_b &&
top_a < top_b + height_b &&
top_a + height_a > top_b \
)
}
footprint_center :: proc(gx, gy, footprint_w, footprint_h: int) -> Vec2 {
return Vec2 {
f32(gx * TILE_SIZE + (footprint_w * TILE_SIZE) / 2),
f32(gy * TILE_SIZE + (footprint_h * TILE_SIZE) / 2),
}
}
footprint_fits_map :: proc(world_map: ^Map, gx, gy, footprint_w, footprint_h: int) -> bool {
if gx < 0 || gy < 0 do return false
if gx + footprint_w > MAP_W do return false
if gy + footprint_h > MAP_H do return false
for row in gy ..< gy + footprint_h {
for column in gx ..< gx + footprint_w {
if !can_build_at(world_map, column, row) do return false
}
}
return true
}
footprint_blocked :: proc(world: ^World, gx, gy, footprint_w, footprint_h: int) -> bool {
for placed_tower in world.towers {
archetype := get_archetype(placed_tower.kind)
if rects_overlap(
gx,
gy,
footprint_w,
footprint_h,
placed_tower.anchor_gx,
placed_tower.anchor_gy,
archetype.footprint_w,
archetype.footprint_h,
) {
return true
}
}
return false
}
get_archetype :: proc(kind: Tower_Kind) -> Tower_Archetype {
return TOWER_ARCHETYPES[kind]
}
try_place_tower :: proc(world: ^World, gx, gy: int, kind: Tower_Kind) -> bool {
if world.phase != .Build do return false
arch := get_archetype(kind)
footprint_w := arch.footprint_w
footprint_h := arch.footprint_h
if !footprint_fits_map(&world.world_map, gx, gy, footprint_w, footprint_h) do return false
if footprint_blocked(world, gx, gy, footprint_w, footprint_h) do return false
if world.gold < arch.cost do return false
world.gold -= arch.cost
append(
&world.towers,
Tower {
position = footprint_center(gx, gy, footprint_w, footprint_h),
anchor_gx = gx,
anchor_gy = gy,
kind = kind,
cooldown = 0,
},
)
return true
tower_stats_at_level :: proc(tower: Tower) -> (damage: int, range: f32) {
arch := TOWER_ARCHETYPES[tower.kind]
damage = arch.damage + tower.upgrade_level * arch.damage_per_level
range = arch.range + f32(tower.upgrade_level) * arch.range_per_level
return
}
find_target_slot :: proc(tower: Tower, world: ^World) -> int {
arch := get_archetype(tower.kind)
best_slot: int = -1
best_dist: f32 = 999999
@@ -129,7 +95,8 @@ find_target_slot :: proc(tower: Tower, world: ^World) -> int {
if e.path_index >= len(world.path) do continue
d := distance(tower.position, e.position)
if d <= arch.range && d < best_dist {
_, range := tower_stats_at_level(tower)
if d <= range && d < best_dist {
best_slot = i
best_dist = d
}
@@ -147,28 +114,70 @@ update_towers :: proc(world: ^World, dt: f32) {
slot := find_target_slot(t^, world)
if slot < 0 do continue
arch := get_archetype(t.kind)
arch := TOWER_ARCHETYPES[t.kind]
damage, _ := tower_stats_at_level(t^)
switch t.kind {
case .Archer:
spawn_projectile(world, t.position, slot, arch.damage)
t.cooldown = arch.fire_rate
spawn_projectile(world, t.position, slot, damage)
case .Cannon:
if target := enemy_at_slot(world, slot); target != nil {
len := distance(t.position, target.position)
if (len > 0.001) {
dir := Vec2 {
(target.position.x - t.position.x) / len,
(target.position.y - t.position.y) / len,
}
spawn_ballistic(world, t.position, dir, damage, arch.splash_radius)
}
}
case .Ice:
if target := enemy_at_slot(world, slot); target != nil {
apply_ice_hit(world, target, damage)
}
}
t.cooldown = arch.fire_rate
}
}
apply_tower_hit :: proc(world: ^World, target: ^Enemy, damage: int) {
target.health -= damage
if target.health <= 0 {
target.active = false
push_event(world, .Enemy_Killed, gold_reward = ENEMY_DEF[target.kind].gold_reward)
}
}
apply_ice_hit :: proc(world: ^World, target: ^Enemy, damage: int) {
apply_tower_hit(world, target, damage)
if target.active do target.slow_timer = ICE_SLOW_DURATION
}
enemy_at_slot :: proc(world: ^World, slot: int) -> ^Enemy {
if slot < 0 || slot >= MAX_ENEMIES do return nil
e := &world.enemies[slot]
if !e.active do return nil
return e
}
render_towers :: proc(world: ^World) {
for t in world.towers {
archetype := get_archetype(t.kind)
archetype := TOWER_ARCHETYPES[t.kind]
pixel_width := f32(archetype.footprint_w * TILE_SIZE)
pixel_height := f32(archetype.footprint_h * TILE_SIZE)
color := archetype.color
rl.DrawRectangle(
i32(t.position.x - pixel_width * 0.5),
i32(t.position.y - pixel_height * 0.5),
i32(pixel_width),
i32(pixel_height),
rl.Color{255, 203, 0, 255},
color,
)
if t.upgrade_level > 0 {
label := fmt.ctprintf("Lv%d", t.upgrade_level + 1) // or just t.upgrade_level
rl.DrawText(label, i32(t.position.x), i32(t.position.y), 14, rl.WHITE)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package game
import rl "vendor:raylib"
can_place_tower :: proc(world: ^World, gx, gy: int, kind: Tower_Kind) -> bool {
if world.phase != .Build do return false
arch := TOWER_ARCHETYPES[kind]
if world.gold < arch.cost do return false
if !footprint_fits_map(&world.world_map, gx, gy, arch.footprint_w, arch.footprint_h) do return false
if footprint_blocked(world, gx, gy, arch.footprint_w, arch.footprint_h) do return false
return true
}
rects_overlap :: proc(
left_a, top_a, width_a, height_a: int,
left_b, top_b, width_b, height_b: int,
) -> bool {
return(
left_a < left_b + width_b &&
left_a + width_a > left_b &&
top_a < top_b + height_b &&
top_a + height_a > top_b \
)
}
footprint_center :: proc(gx, gy, footprint_w, footprint_h: int) -> Vec2 {
return Vec2 {
f32(gx * TILE_SIZE + (footprint_w * TILE_SIZE) / 2),
f32(gy * TILE_SIZE + (footprint_h * TILE_SIZE) / 2),
}
}
footprint_fits_map :: proc(world_map: ^Map, gx, gy, footprint_w, footprint_h: int) -> bool {
if gx < 0 || gy < 0 do return false
if gx + footprint_w > MAP_W do return false
if gy + footprint_h > MAP_H do return false
for column in 0 ..< footprint_h {
for row in 0 ..< footprint_w {
cell_column := gx + column
cell_row := gy + row
if !can_build_at(world_map, cell_column, cell_row) do return false
}
}
return true
}
footprint_blocked :: proc(world: ^World, gx, gy, footprint_w, footprint_h: int) -> bool {
for placed_tower in world.towers {
archetype := TOWER_ARCHETYPES[placed_tower.kind]
if rects_overlap(
gx,
gy,
footprint_w,
footprint_h,
placed_tower.anchor_gx,
placed_tower.anchor_gy,
archetype.footprint_w,
archetype.footprint_h,
) {
return true
}
}
return false
}
try_place_tower :: proc(world: ^World, gx, gy: int, kind: Tower_Kind) -> bool {
if !can_place_tower(world, gx, gy, kind) do return false
arch := TOWER_ARCHETYPES[kind]
world.gold -= arch.cost
append(
&world.towers,
Tower {
position = footprint_center(gx, gy, arch.footprint_w, arch.footprint_h),
anchor_gx = gx,
anchor_gy = gy,
kind = kind,
cooldown = 0,
},
)
return true
}
tower_at_grid :: proc(world: ^World, gx, gy: int) -> ^Tower {
for i in 0 ..< len(world.towers) {
t := &world.towers[i]
arch := TOWER_ARCHETYPES[t.kind]
if gx >= t.anchor_gx &&
gx < t.anchor_gx + arch.footprint_w &&
gy >= t.anchor_gy &&
gy < t.anchor_gy + arch.footprint_h {
return t
}
}
return nil
}
try_upgrade_tower :: proc(world: ^World, gx, gy: int) -> bool {
if world.phase != .Build do return false
t := tower_at_grid(world, gx, gy)
if t == nil do return false
arch := TOWER_ARCHETYPES[t.kind]
if t.upgrade_level >= arch.max_upgrade do return false
cost := arch.upgrade_cost + t.upgrade_level * 25
if world.gold < cost do return false
world.gold -= cost
t.upgrade_level += 1
return true
}
render_placement_preview :: proc(world: ^World) {
if world.phase != .Build do return
mouse := game_mouse()
gx, gy := screen_to_grid(mouse.x, mouse.y)
arch := TOWER_ARCHETYPES[world.selected_tower]
ok := can_place_tower(world, gx, gy, world.selected_tower)
center := footprint_center(gx, gy, arch.footprint_w, arch.footprint_h)
rl.DrawCircleLines(i32(center.x), i32(center.y), arch.range, rl.Fade(rl.SKYBLUE, 0.4))
tint := rl.Fade(rl.GREEN, 0.3)
if !ok do tint = rl.Fade(rl.RED, 0.35)
for row in 0 ..< arch.footprint_h {
for col in 0 ..< arch.footprint_w {
px := (gx + col) * TILE_SIZE
py := (gy + row) * TILE_SIZE
rl.DrawRectangle(i32(px), i32(py), TILE_SIZE, TILE_SIZE, tint)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
package game
import rl "vendor:raylib"
draw_text_centered_in_rect :: proc(
text: cstring,
rect: rl.Rectangle,
font_size: i32,
color: rl.Color,
) {
text_w := rl.MeasureText(text, font_size)
x := i32(rect.x) + (i32(rect.width) - text_w) / 2
y := i32(rect.y) + (i32(rect.height) - font_size) / 2
rl.DrawText(text, x, y, font_size, color)
}
draw_button :: proc(
rect: rl.Rectangle,
label: cstring,
bg: rl.Color,
font_size: i32,
text_color: rl.Color,
) {
rl.DrawRectangleRec(rect, bg)
draw_text_centered_in_rect(label, rect, font_size, text_color)
}
+70 -21
View File
@@ -8,44 +8,93 @@ Game_Phase :: enum {
}
Wave_Spawner :: struct {
enemies_to_spawn: int,
spawn_timer: f32,
spawn_interval: f32,
spawn_timer: f32,
spawn_interval: f32,
recipe: [dynamic]Wave_Entry,
recipe_index: int,
entry_remaining: int,
}
start_wave :: proc(world: ^World) {
if world.phase != .Build do return
world.wave += 1
world.phase = .Combat
delete(world.spawner.recipe)
world.spawner = Wave_Spawner {
enemies_to_spawn = 5 + world.wave * 2,
spawn_timer = 0,
spawn_interval = 0.55,
spawn_timer = 0,
spawn_interval = 0.55,
recipe = build_wave_recipe(world.wave),
recipe_index = 0,
entry_remaining = 0,
}
load_next_recipe_entry(world)
}
Wave_Entry :: struct {
kind: Enemy_Kind,
count: int,
}
build_wave_recipe :: proc(wave: int) -> [dynamic]Wave_Entry {
recipe: [dynamic]Wave_Entry
switch wave {
case 1:
append(&recipe, Wave_Entry{.Grunt, 6})
case 2:
append(&recipe, Wave_Entry{.Grunt, 4})
append(&recipe, Wave_Entry{.Runner, 2})
case:
grunt := 3 + wave
runners := wave / 2
tanks := wave / 3
append(&recipe, Wave_Entry{.Grunt, grunt})
if runners > 0 do append(&recipe, Wave_Entry{.Runner, runners})
if tanks > 0 do append(&recipe, Wave_Entry{.Tank, tanks})
}
return recipe
}
load_next_recipe_entry :: proc(world: ^World) {
s := &world.spawner
if s.recipe_index >= len(s.recipe) {
s.entry_remaining = 0
return
}
entry := s.recipe[s.recipe_index]
s.entry_remaining = entry.count
}
clear_wave :: proc(world: ^World) -> bool {
s := world.spawner
if s.recipe_index < len(s.recipe) do return false
if s.entry_remaining > 0 do return false
return active_enemy_count(world) == 0
}
update_wave :: proc(world: ^World, dt: f32) {
if world.phase != .Combat do return
if world.spawner.enemies_to_spawn <= 0 do return
if len(world.path) < 2 do return
s := &world.spawner
if s.entry_remaining <= 0 && s.recipe_index >= len(s.recipe) do return
world.spawner.spawn_timer -= dt
if world.spawner.spawn_timer <= 0 {
health := 15 + world.wave * 5
speed := 55 + f32(world.wave) * 5
spawn_enemy(world, health, speed)
world.spawner.enemies_to_spawn -= 1
world.spawner.spawn_timer = world.spawner.spawn_interval
s.spawn_timer -= dt
if s.spawn_timer > 0 do return
if s.entry_remaining <= 0 {
s.recipe_index += 1
load_next_recipe_entry(world)
if s.entry_remaining <= 0 do return
}
}
wave_clear :: proc(world: ^World) -> bool {
if world.spawner.enemies_to_spawn > 0 do return false
return active_enemy_count(world) == 0
kind := s.recipe[s.recipe_index].kind
spawn_enemy(world, kind)
s.entry_remaining -= 1
s.spawn_timer = s.spawn_interval
}
update_phase :: proc(world: ^World) {
if world.phase == .Combat && wave_clear(world) {
if world.phase == .Combat && clear_wave(world) {
world.phase = .Build
push_event(world, .Wave_Survived, gold_reward = 25)
}
@@ -56,7 +105,7 @@ check_end :: proc(world: ^World) {
world.phase = .Game_Over
}
if world.wave >= MAX_WAVES && world.phase == .Build && wave_clear(world) {
if world.wave >= MAX_WAVES && world.phase == .Build && clear_wave(world) {
world.phase = .Victory
}
}
+145
View File
@@ -0,0 +1,145 @@
package game
import "core:testing"
@(test)
test_start_wave_enters_combat :: proc(t: ^testing.T) {
world := World {
phase = .Combat,
}
world.wave = 0
start_wave(&world)
testing.expect(t, world.phase == .Combat)
testing.expect(t, world.wave == 0)
}
@(test)
test_start_wave_from_build :: proc(t: ^testing.T) {
world := World {
phase = .Build,
}
world.wave = 0
start_wave(&world)
defer delete(world.spawner.recipe)
testing.expect(t, world.phase == .Combat)
testing.expect(t, world.wave == 1)
testing.expect(t, world.spawner.spawn_timer == 0)
testing.expect(t, world.spawner.spawn_interval == 0.55)
testing.expect(t, world.spawner.recipe_index == 0)
}
@(test)
test_clear_wave_not_cleared :: proc(t: ^testing.T) {
world := World{}
world.spawner.recipe = make([dynamic]Wave_Entry, context.allocator)
append(&world.spawner.recipe, Wave_Entry{})
defer delete(world.spawner.recipe)
world.spawner.recipe_index = 0
world.spawner.entry_remaining = 0
result := clear_wave(&world)
testing.expect(t, result == false)
}
@(test)
test_clear_wave_cleared :: proc(t: ^testing.T) {
world := World{}
world.spawner.recipe = make([dynamic]Wave_Entry, context.allocator)
append(&world.spawner.recipe, Wave_Entry{})
defer delete(world.spawner.recipe)
world.spawner.recipe_index = 1
world.spawner.entry_remaining = 0
result := clear_wave(&world)
testing.expect(t, result == true)
}
@(test)
test_update_wave_ignores_build_phase :: proc(t: ^testing.T) {
world := init_world()
defer delete(world.path)
world.phase = .Build
world.spawner.spawn_timer = 0
world.spawner.entry_remaining = 5
update_wave(&world, 1.0)
testing.expect(t, active_enemy_count(&world) == 0)
}
@(test)
test_update_wave_waits_for_spawn_timer :: proc(t: ^testing.T) {
world := init_world()
defer delete(world.path)
world.phase = .Combat
world.spawner.recipe = make([dynamic]Wave_Entry, context.allocator)
append(&world.spawner.recipe, Wave_Entry{.Grunt, 1})
defer delete(world.spawner.recipe)
world.spawner.recipe_index = 0
world.spawner.entry_remaining = 1
world.spawner.spawn_timer = 0.5
update_wave(&world, 0.1)
testing.expect(t, active_enemy_count(&world) == 0)
testing.expect(t, world.spawner.spawn_timer == 0.4)
}
@(test)
test_update_wave_spawns_and_resets_timer :: proc(t: ^testing.T) {
world := init_world()
defer delete(world.path)
world.phase = .Combat
world.wave = 1
world.spawner.recipe = make([dynamic]Wave_Entry, context.allocator)
append(&world.spawner.recipe, Wave_Entry{.Grunt, 2})
defer delete(world.spawner.recipe)
world.spawner.recipe_index = 0
world.spawner.entry_remaining = 2
world.spawner.spawn_timer = 0
world.spawner.spawn_interval = 0.55
update_wave(&world, 0.55)
testing.expect(t, active_enemy_count(&world) == 1)
testing.expect(t, world.spawner.entry_remaining == 1)
testing.expect(t, world.spawner.spawn_timer == 0.55)
found_grunt := false
for i in 0 ..< MAX_ENEMIES {
if world.enemies[i].active {
testing.expect(t, world.enemies[i].kind == .Grunt)
found_grunt = true
}
}
testing.expect(t, found_grunt)
}
@(test)
test_update_phase_from_build_phase :: proc(t: ^testing.T) {
world := World{}
defer delete(world.events)
world.phase = .Build
update_phase(&world)
testing.expect(t, world.phase == .Build)
}
@(test)
test_update_phase_from_combat_phase :: proc(t: ^testing.T) {
world := World{}
defer delete(world.events)
world.phase = .Combat
update_phase(&world)
testing.expect(t, world.phase == .Build)
}
+16 -14
View File
@@ -1,24 +1,26 @@
package game
World :: struct {
gold: int,
base_health: int,
world_map: Map,
path: [dynamic]Vec2,
enemies: [MAX_ENEMIES]Enemy,
towers: [dynamic]Tower,
events: [dynamic]Event,
phase: Game_Phase,
wave: int,
spawner: Wave_Spawner,
projectiles: [MAX_PROJECTILES]Projectile,
gold: int,
base_health: int,
world_map: Map,
path: [dynamic]Vec2,
enemies: [MAX_ENEMIES]Enemy,
towers: [dynamic]Tower,
events: [dynamic]Event,
phase: Game_Phase,
wave: int,
spawner: Wave_Spawner,
projectiles: [MAX_PROJECTILES]Projectile,
selected_tower: Tower_Kind,
}
init_world :: proc() -> World {
world := World {
gold = 100,
base_health = 20,
phase = .Build,
gold = STARTING_GOLD,
base_health = STARTING_BASE_HEALTH,
phase = .Build,
selected_tower = .Archer,
}
init_map(&world.world_map)
build_path(&world.world_map, &world.path)