3 Commits
Author SHA1 Message Date
codegirl007 067ad60a3d fix it! 2026-06-16 20:29:48 -07:00
codegirl007 6a3b2e1bd1 still segfaulting 2026-06-16 20:19:41 -07:00
codegirl007 46114a1dbb I don't know what i did wrong 2026-06-16 20:09:56 -07:00
29 changed files with 976 additions and 1054 deletions
-14
View File
@@ -1,14 +0,0 @@
## 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
+601
View File
@@ -0,0 +1,601 @@
package clay
import "core:c"
when ODIN_OS == .Windows {
foreign import Clay "windows/clay.lib"
} else when ODIN_OS == .Linux {
foreign import Clay "linux/clay.a"
} else when ODIN_OS == .Darwin {
when ODIN_ARCH == .arm64 {
foreign import Clay "macos-arm64/clay.a"
} else {
foreign import Clay "macos/clay.a"
}
} else when ODIN_ARCH == .wasm32 || ODIN_ARCH == .wasm64p32 {
foreign import Clay "wasm/clay.o"
}
String :: struct {
isStaticallyAllocated: c.bool,
length: c.int32_t,
chars: [^]c.char,
}
StringSlice :: struct {
length: c.int32_t,
chars: [^]c.char,
baseChars: [^]c.char,
}
Vector2 :: [2]c.float
Dimensions :: struct {
width: c.float,
height: c.float,
}
Arena :: struct {
nextAllocation: uintptr,
capacity: c.size_t,
memory: [^]c.char,
}
BoundingBox :: struct {
x: c.float,
y: c.float,
width: c.float,
height: c.float,
}
Color :: [4]c.float
CornerRadius :: struct {
topLeft: c.float,
topRight: c.float,
bottomLeft: c.float,
bottomRight: c.float,
}
ElementId :: struct {
id: u32,
offset: u32,
baseId: u32,
stringId: String,
}
ElementIdArray :: struct {
capacity: i32,
length: i32,
internalArray: [^]ElementId,
}
when ODIN_OS == .Windows {
EnumBackingType :: u32
} else {
EnumBackingType :: u8
}
RenderCommandType :: enum EnumBackingType {
None,
Rectangle,
Border,
Text,
Image,
ScissorStart,
ScissorEnd,
OverlayColorStart,
OverlayColorEnd,
Custom,
}
RectangleElementConfig :: struct {
color: Color,
}
TextWrapMode :: enum EnumBackingType {
Words,
Newlines,
None,
}
TextAlignment :: enum EnumBackingType {
Left,
Center,
Right,
}
TextElementConfig :: struct {
userData: rawptr,
textColor: Color,
fontId: u16,
fontSize: u16,
letterSpacing: u16,
lineHeight: u16,
wrapMode: TextWrapMode,
textAlignment: TextAlignment,
}
AspectRatioElementConfig :: struct {
aspectRatio: f32,
}
ImageElementConfig :: struct {
imageData: rawptr,
}
CustomElementConfig :: struct {
customData: rawptr,
}
BorderWidth :: struct {
left: u16,
right: u16,
top: u16,
bottom: u16,
betweenChildren: u16,
}
BorderElementConfig :: struct {
color: Color,
width: BorderWidth,
}
TransitionData :: struct {
boundingBox: BoundingBox,
backgroundColor: Color,
overlayColor: Color,
borderColor: Color,
borderWidth: BorderWidth,
}
TransitionState :: enum c.int {
Idle,
Entering,
Transitioning,
Exiting,
}
TransitionProperty :: enum c.int {
X,
Y,
Width,
Height,
BackgroundColor,
OverlayColor,
CornerRadius,
BorderColor,
BorderWidth,
}
TransitionPropertyFlags :: bit_set[TransitionProperty;c.int]
TransitionPropertyPosition :: TransitionPropertyFlags{.X, .Y}
TransitionPropertyDimensions :: TransitionPropertyFlags{.Width, .Height}
TransitionPropertyBoundingBox :: TransitionPropertyPosition + TransitionPropertyDimensions
TransitionPropertyBorder :: TransitionPropertyFlags{.BorderColor, .BorderWidth}
TransitionCallbackArguments :: struct {
transitionState: TransitionState,
initial: TransitionData,
current: ^TransitionData,
target: TransitionData,
elapsedTime: f32,
duration: f32,
properties: TransitionPropertyFlags,
}
TransitionEnterTriggerType :: enum EnumBackingType {
SkipOnFirstParentFrame,
TriggerOnFirstParentFrame,
}
TransitionExitTriggerType :: enum EnumBackingType {
SkipWhenParentExits,
TriggerWhenParentExits,
}
TransitionInteractionHandlingType :: enum EnumBackingType {
DisableInteractionsWhileTransitioningPosition,
AllowInteractionsWhileTransitioningPosition,
}
ExitTransitionSiblingOrdering :: enum EnumBackingType {
UnderneathSiblings,
NaturalOrder,
AboveSiblings,
}
TransitionElementConfig :: struct {
handler: proc "c" (args: TransitionCallbackArguments) -> bool,
duration: f32,
properties: TransitionPropertyFlags,
interactionHandling: TransitionInteractionHandlingType,
enter: struct {
setInitialState: proc "c" (initialState: TransitionData, properties: TransitionPropertyFlags) -> TransitionData,
trigger: TransitionEnterTriggerType,
},
exit: struct {
setFinalState: proc "c" (finalState: TransitionData, properties: TransitionPropertyFlags) -> TransitionData,
trigger: TransitionExitTriggerType,
siblingOrdering: ExitTransitionSiblingOrdering,
},
}
ClipElementConfig :: struct {
horizontal: bool, // clip overflowing elements on the "X" axis
vertical: bool, // clip overflowing elements on the "Y" axis
childOffset: Vector2, // offsets the [X,Y] positions of all child elements, primarily for scrolling containers
}
FloatingAttachPointType :: enum EnumBackingType {
LeftTop,
LeftCenter,
LeftBottom,
CenterTop,
CenterCenter,
CenterBottom,
RightTop,
RightCenter,
RightBottom,
}
FloatingAttachPoints :: struct {
element: FloatingAttachPointType,
parent: FloatingAttachPointType,
}
PointerCaptureMode :: enum EnumBackingType {
Capture,
Passthrough,
}
FloatingAttachToElement :: enum EnumBackingType {
None,
Parent,
ElementWithId,
Root,
}
FloatingClipToElement :: enum EnumBackingType {
None,
AttachedParent,
}
FloatingElementConfig :: struct {
offset: Vector2,
expand: Dimensions,
parentId: u32,
zIndex: i16,
attachment: FloatingAttachPoints,
pointerCaptureMode: PointerCaptureMode,
attachTo: FloatingAttachToElement,
clipTo: FloatingClipToElement,
}
TextRenderData :: struct {
stringContents: StringSlice,
textColor: Color,
fontId: u16,
fontSize: u16,
letterSpacing: u16,
lineHeight: u16,
}
RectangleRenderData :: struct {
backgroundColor: Color,
cornerRadius: CornerRadius,
}
ImageRenderData :: struct {
backgroundColor: Color,
cornerRadius: CornerRadius,
imageData: rawptr,
}
CustomRenderData :: struct {
backgroundColor: Color,
cornerRadius: CornerRadius,
customData: rawptr,
}
ClipRenderData :: struct {
horizontal: bool,
vertical: bool,
}
OverlayColorRenderData :: struct {
color: Color,
}
BorderRenderData :: struct {
color: Color,
cornerRadius: CornerRadius,
width: BorderWidth,
}
RenderCommandData :: struct #raw_union {
rectangle: RectangleRenderData,
text: TextRenderData,
image: ImageRenderData,
custom: CustomRenderData,
border: BorderRenderData,
clip: ClipRenderData,
overlayColor: OverlayColorRenderData,
}
RenderCommand :: struct {
boundingBox: BoundingBox,
renderData: RenderCommandData,
userData: rawptr,
id: u32,
zIndex: i16,
commandType: RenderCommandType,
}
ScrollContainerData :: struct {
// Note: This is a pointer to the real internal scroll position, mutating it may cause a change in final layout.
// Intended for use with external functionality that modifies scroll position, such as scroll bars or auto scrolling.
scrollPosition: ^Vector2,
scrollContainerDimensions: Dimensions,
contentDimensions: Dimensions,
config: ClipElementConfig,
// Indicates whether an actual scroll container matched the provided ID or if the default struct was returned.
found: bool,
}
ElementData :: struct {
boundingBox: BoundingBox,
found: bool,
}
PointerDataInteractionState :: enum EnumBackingType {
PressedThisFrame,
Pressed,
ReleasedThisFrame,
Released,
}
PointerData :: struct {
position: Vector2,
state: PointerDataInteractionState,
}
SizingType :: enum EnumBackingType {
Fit,
Grow,
Percent,
Fixed,
}
SizingConstraintsMinMax :: struct {
min: c.float,
max: c.float,
}
SizingConstraints :: struct #raw_union {
sizeMinMax: SizingConstraintsMinMax,
sizePercent: c.float,
}
SizingAxis :: struct {
// Note: `min` is used for CLAY_SIZING_PERCENT, slightly different to clay.h due to lack of C anonymous unions
constraints: SizingConstraints,
type: SizingType,
}
Sizing :: struct {
width: SizingAxis,
height: SizingAxis,
}
Padding :: struct {
left: u16,
right: u16,
top: u16,
bottom: u16,
}
LayoutDirection :: enum EnumBackingType {
LeftToRight,
TopToBottom,
}
LayoutAlignmentX :: enum EnumBackingType {
Left,
Right,
Center,
}
LayoutAlignmentY :: enum EnumBackingType {
Top,
Bottom,
Center,
}
ChildAlignment :: struct {
x: LayoutAlignmentX,
y: LayoutAlignmentY,
}
LayoutConfig :: struct {
sizing: Sizing,
padding: Padding,
childGap: u16,
childAlignment: ChildAlignment,
layoutDirection: LayoutDirection,
}
ClayArray :: struct($type: typeid) {
capacity: i32,
length: i32,
internalArray: [^]type,
}
ElementDeclaration :: struct {
layout: LayoutConfig,
backgroundColor: Color,
overlayColor: Color,
cornerRadius: CornerRadius,
aspectRatio: AspectRatioElementConfig,
image: ImageElementConfig,
floating: FloatingElementConfig,
custom: CustomElementConfig,
clip: ClipElementConfig,
border: BorderElementConfig,
transition: TransitionElementConfig,
userData: rawptr,
}
ErrorType :: enum EnumBackingType {
TextMeasurementFunctionNotProvided,
ArenaCapacityExceeded,
ElementsCapacityExceeded,
TextMeasurementCapacityExceeded,
DuplicateId,
FloatingContainerParentNotFound,
PercentageOver1,
InternalError,
UnbalancedOpenClose,
}
ErrorData :: struct {
errorType: ErrorType,
errorText: String,
userData: rawptr,
}
ErrorHandler :: struct {
handler: proc "c" (errorData: ErrorData),
userData: rawptr,
}
Context :: struct {} // opaque structure, only use as a pointer
@(link_prefix = "Clay_", default_calling_convention = "c")
foreign Clay {
_OpenElement :: proc() ---
_OpenElementWithId :: proc(id: ElementId) ---
_CloseElement :: proc() ---
MinMemorySize :: proc() -> u32 ---
CreateArenaWithCapacityAndMemory :: proc(capacity: c.size_t, offset: [^]u8) -> Arena ---
SetPointerState :: proc(position: Vector2, pointerDown: bool) ---
GetPointerState :: proc() -> PointerData ---
Initialize :: proc(arena: Arena, layoutDimensions: Dimensions, errorHandler: ErrorHandler) -> ^Context ---
GetCurrentContext :: proc() -> ^Context ---
SetCurrentContext :: proc(ctx: ^Context) ---
UpdateScrollContainers :: proc(enableDragScrolling: bool, scrollDelta: Vector2, deltaTime: c.float) ---
SetLayoutDimensions :: proc(dimensions: Dimensions) ---
BeginLayout :: proc() ---
EndLayout :: proc(deltaTime: c.float) -> ClayArray(RenderCommand) ---
GetOpenElementId :: proc() -> u32 ---
GetElementId :: proc(id: String) -> ElementId ---
GetElementIdWithIndex :: proc(id: String, index: u32) -> ElementId ---
GetElementData :: proc(id: ElementId) -> ElementData ---
Hovered :: proc() -> bool ---
OnHover :: proc(onHoverFunction: proc "c" (id: ElementId, pointerData: PointerData, userData: rawptr), userData: rawptr) ---
PointerOver :: proc(id: ElementId) -> bool ---
GetPointerOverIds :: proc() -> ElementIdArray ---
GetScrollOffset :: proc() -> Vector2 ---
GetScrollContainerData :: proc(id: ElementId) -> ScrollContainerData ---
SetMeasureTextFunction :: proc(measureTextFunction: proc "c" (text: StringSlice, config: ^TextElementConfig, userData: rawptr) -> Dimensions, userData: rawptr) ---
SetQueryScrollOffsetFunction :: proc(queryScrollOffsetFunction: proc "c" (elementId: u32, userData: rawptr) -> Vector2, userData: rawptr) ---
RenderCommandArray_Get :: proc(array: ^ClayArray(RenderCommand), index: i32) -> ^RenderCommand ---
SetDebugModeEnabled :: proc(enabled: bool) ---
IsDebugModeEnabled :: proc() -> bool ---
SetCullingEnabled :: proc(enabled: bool) ---
GetMaxElementCount :: proc() -> i32 ---
SetMaxElementCount :: proc(maxElementCount: i32) ---
GetMaxMeasureTextCacheWordCount :: proc() -> i32 ---
SetMaxMeasureTextCacheWordCount :: proc(maxMeasureTextCacheWordCount: i32) ---
ResetMeasureTextCache :: proc() ---
EaseOut :: proc(arguments: TransitionCallbackArguments) -> bool ---
}
@(link_prefix = "Clay_", default_calling_convention = "c", private)
foreign Clay {
_ConfigureOpenElement :: proc(config: ElementDeclaration) ---
_HashString :: proc(key: String, seed: u32) -> ElementId ---
_HashStringWithOffset :: proc(key: String, index: u32, seed: u32) -> ElementId ---
_OpenTextElement :: proc(text: String, textConfig: TextElementConfig) ---
}
ConfigureOpenElement :: proc(config: ElementDeclaration) -> bool {
_ConfigureOpenElement(config)
return true
}
@(deferred_none = _CloseElement)
UI_WithId :: proc(id: ElementId) -> proc(config: ElementDeclaration) -> bool {
_OpenElementWithId(id)
return ConfigureOpenElement
}
@(deferred_none = _CloseElement)
UI_AutoId :: proc() -> proc(config: ElementDeclaration) -> bool {
_OpenElement()
return ConfigureOpenElement
}
UI :: proc {
UI_WithId,
UI_AutoId,
}
Text :: proc {
TextStatic,
TextDynamic,
}
TextStatic :: proc($text: string, config: TextElementConfig) {
wrapped := MakeString(text)
wrapped.isStaticallyAllocated = true
_OpenTextElement(wrapped, config)
}
TextDynamic :: proc(text: string, config: TextElementConfig) {
_OpenTextElement(MakeString(text), config)
}
PaddingAll :: proc(allPadding: u16) -> Padding {
return {left = allPadding, right = allPadding, top = allPadding, bottom = allPadding}
}
BorderOutside :: proc(width: u16) -> BorderWidth {
return {width, width, width, width, 0}
}
BorderAll :: proc(width: u16) -> BorderWidth {
return {width, width, width, width, width}
}
CornerRadiusAll :: proc(radius: f32) -> CornerRadius {
return CornerRadius{radius, radius, radius, radius}
}
SizingFit :: proc(sizeMinMax: SizingConstraintsMinMax = {}) -> SizingAxis {
return SizingAxis{type = SizingType.Fit, constraints = {sizeMinMax = sizeMinMax}}
}
SizingGrow :: proc(sizeMinMax: SizingConstraintsMinMax = {}) -> SizingAxis {
return SizingAxis{type = SizingType.Grow, constraints = {sizeMinMax = sizeMinMax}}
}
SizingFixed :: proc(size: c.float) -> SizingAxis {
return SizingAxis{type = SizingType.Fixed, constraints = {sizeMinMax = {size, size}}}
}
SizingPercent :: proc(sizePercent: c.float) -> SizingAxis {
return SizingAxis{type = SizingType.Percent, constraints = {sizePercent = sizePercent}}
}
MakeString :: proc(label: string) -> String {
return String{chars = raw_data(label), length = cast(c.int)len(label)}
}
ID :: proc(label: string, index: u32 = 0) -> ElementId {
return _HashString(MakeString(label), index)
}
ID_LOCAL :: proc(label: string, index: u32 = 0) -> ElementId {
return _HashStringWithOffset(MakeString(label), index, GetOpenElementId())
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -19
View File
@@ -3,31 +3,30 @@ package game
import rl "vendor:raylib"
run :: proc() {
init_display()
defer {
shutdown_display()
rl.CloseWindow()
}
rl.InitWindow(SCREEN_W, SCREEN_H, "Tower Defense")
defer rl.CloseWindow()
rl.SetTargetFPS(60)
init_ui()
defer destroy_ui()
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()
ui_prepare_frame(&world, dt)
update_world(&world, dt)
begin_game_draw()
render_world(&world)
render_overlay(&world)
render_controls(&world)
end_game_draw()
}
}
rl.BeginDrawing()
rl.ClearBackground(rl.Color{34, 139, 34, 255})
render_world(&world)
render_ui()
rl.EndDrawing()
}
delete(world.path)
delete(world.towers)
delete(world.events)
}
+6 -15
View File
@@ -6,7 +6,6 @@ Command_Kind :: enum {
None,
Place_Tower,
Start_Wave,
Upgrade_Tower,
}
Command :: struct {
@@ -16,24 +15,18 @@ 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 rl.IsMouseButtonPressed(.RIGHT) {
mouse := game_mouse()
if world.phase == .Build && rl.IsMouseButtonPressed(.LEFT) {
mouse := rl.GetMousePosition()
gx, gy := screen_to_grid(mouse.x, mouse.y)
return Command{kind = .Upgrade_Tower, gx = gx, gy = gy}
return Command{kind = .Place_Tower, gx = gx, gy = gy}
}
if world.phase == .Build && rl.IsMouseButtonPressed(.LEFT) {
mouse := game_mouse()
if rl.IsMouseButtonPressed(.LEFT) {
mouse := rl.GetMousePosition()
gx, gy := screen_to_grid(mouse.x, mouse.y)
return Command{kind = .Place_Tower, gx = gx, gy = gy}
}
@@ -45,11 +38,9 @@ execute_command :: proc(world: ^World, cmd: Command) {
switch cmd.kind {
case .None:
case .Place_Tower:
try_place_tower(world, cmd.gx, cmd.gy, world.selected_tower)
try_place_tower(world, cmd.gx, cmd.gy, .Archer)
case .Start_Wave:
start_wave(world)
case .Upgrade_Tower:
try_upgrade_tower(world, cmd.gx, cmd.gy)
}
}
+2 -54
View File
@@ -1,7 +1,5 @@
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
@@ -9,63 +7,13 @@ 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
@@ -1,76 +0,0 @@
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
@@ -1,62 +0,0 @@
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
@@ -1,29 +0,0 @@
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)
}
+13 -71
View File
@@ -1,7 +1,6 @@
package game
import "core:fmt"
import "core:sys/posix"
import rl "vendor:raylib"
Enemy :: struct {
@@ -10,55 +9,7 @@ Enemy :: struct {
max_health: int,
active: bool,
path_index: int,
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,
},
speed: f32,
}
acquire_enemy :: proc(world: ^World) -> ^Enemy {
@@ -72,34 +23,35 @@ acquire_enemy :: proc(world: ^World) -> ^Enemy {
return nil
}
spawn_enemy :: proc(world: ^World, kind: Enemy_Kind) {
spawn_enemy :: proc(world: ^World, health: int, speed: f32) {
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,
base_speed = speed,
slow_timer = 0,
speed = speed,
path_index = 1,
kind = kind,
active = true,
}
}
update_enemies :: proc(world: ^World, dt: f32) {
if len(world.path) < 2 do return
spawn_group :: proc(world: ^World, count: int) {
for _ in 0 ..< count {
spawn_enemy(world, 20, 60)
}
}
update_enemies :: proc(world: ^World, dt: f32) {
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
@@ -107,12 +59,7 @@ update_enemies :: proc(world: ^World, dt: f32) {
}
target := world.path[e.path_index]
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)
move_toward(&e.position, target, e.speed, dt)
dx := target.x - e.position.x
dy := target.y - e.position.y
@@ -135,12 +82,7 @@ render_enemies :: proc(world: ^World) {
for i in 0 ..< MAX_ENEMIES {
e := world.enemies[i]
if !e.active do continue
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.DrawCircle(i32(e.position.x), i32(e.position.y), 11, rl.MAROON)
rl.DrawText(
fmt.ctprintf("%d", e.health),
i32(e.position.x) - 4,
+50
View File
@@ -0,0 +1,50 @@
package game
import clay "../clay-odin"
import "core:fmt"
import rl "vendor:raylib"
UI_BAR_COLOR :: clay.Color{20, 40, 20, 200}
UI_TEXT_COLOR :: clay.Color{240, 240, 230, 255}
UI_GOLD_COLOR :: clay.Color{255, 215, 80, 255}
ui_text_scratch: [64]u8
build_hud :: proc(world: ^World) {
if clay.UI(clay.ID("HudBar"))(
{
layout = {
layoutDirection = .LeftToRight,
sizing = {width = clay.SizingGrow({}), height = clay.SizingFixed(28)},
padding = clay.PaddingAll(6),
childGap = 8,
childAlignment = {y = .Center},
},
backgroundColor = UI_BAR_COLOR,
},
) {
gold_text := fmt.bprintf(ui_text_scratch[:], "Gold: %d", world.gold)
clay.Text(
gold_text,
clay.TextElementConfig{textColor = UI_GOLD_COLOR, fontId = UI_FONT_ID, fontSize = 18},
)
}
}
render_hud :: proc(world: ^World) {
rl.DrawText(fmt.ctprintf("Base HP: %d", world.base_health), 10, 32, 22, rl.BLACK)
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)
}
}
+12 -5
View File
@@ -11,10 +11,17 @@ Map :: struct {
tiles: [MAP_H][MAP_W]Tile_Kind,
}
TILE_COLORS: [Tile_Kind]rl.Color = {
.Blocked = {35, 95, 35, 255},
.Path = {130, 85, 45, 255},
.Build = {50, 130, 50, 255},
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
}
init_map :: proc(world_map: ^Map) {
@@ -50,7 +57,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_COLORS[world.world_map.tiles[y][x]]
c := tile_color(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
@@ -1,62 +0,0 @@
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
@@ -1,33 +0,0 @@
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)
}
}
+24 -104
View File
@@ -3,19 +3,11 @@ package game
import rl "vendor:raylib"
Projectile :: struct {
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
active: bool,
position: Vec2,
target_slot: int,
damage: int,
speed: f32,
}
acquire_projectile :: proc(world: ^World) -> ^Projectile {
@@ -41,87 +33,37 @@ 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,
splash_radius = 0,
active = true,
position = from,
target_slot = target_slot,
damage = damage,
speed = PROJECTILE_SPEED,
}
}
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
switch p.mode {
case .Homing:
update_homing_projectile(world, p, dt)
case .Ballistic:
update_ballistic_projectile(world, p, dt)
target, ok := enemy_still_on_map(world, p.target_slot)
if !ok {
p.active = false
continue
}
}
}
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
}
move_toward(&p.position, target.position, p.speed, dt)
move_toward(&p.position, target.position, p.speed, dt)
if distance(p.position, target.position) <= PROJECTILE_HIT_RADIUS {
target.health -= p.damage
p.active = false
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
if target.health <= 0 {
target.active = false
push_event(world, .Enemy_Killed, gold_reward = 5)
}
}
}
}
@@ -129,29 +71,7 @@ render_projectiles :: proc(world: ^World) {
for i in 0 ..< MAX_PROJECTILES {
p := world.projectiles[i]
if !p.active do continue
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)
rl.DrawCircle(i32(p.position.x), i32(p.position.y), 4, rl.GOLD)
}
}
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
View File
@@ -4,7 +4,6 @@ render_world :: proc(world: ^World) {
render_map(world)
render_enemies(world)
render_towers(world)
render_placement_preview(world)
render_projectiles(world)
}
+102 -111
View File
@@ -1,91 +1,125 @@
package game
import "core:fmt"
import rl "vendor:raylib"
Tower :: struct {
upgrade_level: int,
position: Vec2,
kind: Tower_Kind,
cooldown: f32,
anchor_gx: int,
anchor_gy: int,
position: Vec2,
kind: Tower_Kind,
cooldown: f32,
anchor_gx: int,
anchor_gy: int,
}
Tower_Kind :: enum {
Archer,
Cannon,
Ice,
}
Tower_Archetype :: struct {
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,
kind: Tower_Kind,
range: f32,
damage: int,
fire_rate: f32,
cost: int,
footprint_w: int, // 1 column wide
footprint_h: int, // two rows tall
}
TOWER_ARCHETYPES: [Tower_Kind]Tower_Archetype = {
.Archer = {
range = 100,
kind = .Archer,
range = 50,
damage = 12,
fire_rate = 0.45,
cost = 50,
cost = TOWER_COST,
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,
},
}
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
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
}
find_target_slot :: proc(tower: Tower, world: ^World) -> int {
arch := get_archetype(tower.kind)
best_slot: int = -1
best_dist: f32 = 999999
@@ -95,8 +129,7 @@ find_target_slot :: proc(tower: Tower, world: ^World) -> int {
if e.path_index >= len(world.path) do continue
d := distance(tower.position, e.position)
_, range := tower_stats_at_level(tower)
if d <= range && d < best_dist {
if d <= arch.range && d < best_dist {
best_slot = i
best_dist = d
}
@@ -114,70 +147,28 @@ update_towers :: proc(world: ^World, dt: f32) {
slot := find_target_slot(t^, world)
if slot < 0 do continue
arch := TOWER_ARCHETYPES[t.kind]
arch := get_archetype(t.kind)
damage, _ := tower_stats_at_level(t^)
switch t.kind {
case .Archer:
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)
}
spawn_projectile(world, t.position, slot, arch.damage)
t.cooldown = arch.fire_rate
}
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 := TOWER_ARCHETYPES[t.kind]
archetype := get_archetype(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),
color,
rl.Color{255, 203, 0, 255},
)
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
@@ -1,139 +0,0 @@
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)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package game
import clay "../clay-odin"
import rl "vendor:raylib"
ui_memory: [^]u8
ui_arena: clay.Arena
ui_render_commands: clay.ClayArray(clay.RenderCommand)
ui_error_handler :: proc "c" (data: clay.ErrorData) {
_ = data
}
init_ui :: proc() {
ui_register_font()
min_size := clay.MinMemorySize()
ui_memory = make([^]u8, min_size)
ui_arena = clay.CreateArenaWithCapacityAndMemory(uint(min_size), ui_memory)
clay.Initialize(ui_arena, {f32(SCREEN_W), f32(SCREEN_H)}, {handler = ui_error_handler})
clay.SetMeasureTextFunction(measure_text, nil)
}
destroy_ui :: proc() {
free(ui_memory)
}
ui_prepare_frame :: proc(world: ^World, dt: f32) {
mouse := rl.GetMousePosition()
clay.SetLayoutDimensions({f32(SCREEN_W), f32(SCREEN_H)})
clay.SetPointerState({mouse.x, mouse.y}, rl.IsMouseButtonDown(.LEFT))
clay.UpdateScrollContainers(false, {}, dt)
clay.BeginLayout()
build_hud(world)
ui_render_commands = clay.EndLayout(dt)
}
render_ui :: proc() {
clay_raylib_render(&ui_render_commands)
}
+69
View File
@@ -0,0 +1,69 @@
package game
import clay "../clay-odin"
import "core:math"
import "core:strings"
import rl "vendor:raylib"
UI_FONT_ID :: 0
ui_font: rl.Font
ui_register_font :: proc() {
ui_font = rl.GetFontDefault()
}
clay_color_to_rl :: proc(c: clay.Color) -> rl.Color {
return {u8(c.r), u8(c.g), u8(c.b), u8(c.a)}
}
measure_text :: proc "c" (
text: clay.StringSlice,
config: ^clay.TextElementConfig,
_: rawptr,
) -> clay.Dimensions {
return {width = f32(text.length) * f32(config.fontSize) * 0.55, height = f32(config.fontSize)}
}
clay_raylib_render :: proc(commands: ^clay.ClayArray(clay.RenderCommand)) {
for i in 0 ..< commands.length {
cmd := clay.RenderCommandArray_Get(commands, i)
bounds := cmd.boundingBox
#partial switch cmd.commandType {
case .None:
case .Text:
cfg := cmd.renderData.text
text := string(cfg.stringContents.chars[:cfg.stringContents.length])
cstr := strings.clone_to_cstring(text, context.temp_allocator)
rl.DrawTextEx(
ui_font,
cstr,
{bounds.x, bounds.y},
f32(cfg.fontSize),
f32(cfg.letterSpacing),
clay_color_to_rl(cfg.textColor),
)
case .Rectangle:
cfg := cmd.renderData.rectangle
rl.DrawRectangle(
i32(math.round(bounds.x)),
i32(math.round(bounds.y)),
i32(math.round(bounds.width)),
i32(math.round(bounds.height)),
clay_color_to_rl(cfg.backgroundColor),
)
case .ScissorStart:
rl.BeginScissorMode(
i32(math.round(bounds.x)),
i32(math.round(bounds.y)),
i32(math.round(bounds.width)),
i32(math.round(bounds.height)),
)
case .ScissorEnd:
rl.EndScissorMode()
}
}
}
-27
View File
@@ -1,27 +0,0 @@
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)
}
+21 -70
View File
@@ -8,93 +8,44 @@ Game_Phase :: enum {
}
Wave_Spawner :: struct {
spawn_timer: f32,
spawn_interval: f32,
recipe: [dynamic]Wave_Entry,
recipe_index: int,
entry_remaining: int,
enemies_to_spawn: int,
spawn_timer: f32,
spawn_interval: f32,
}
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 {
spawn_timer = 0,
spawn_interval = 0.55,
recipe = build_wave_recipe(world.wave),
recipe_index = 0,
entry_remaining = 0,
enemies_to_spawn = 5 + world.wave * 2,
spawn_timer = 0,
spawn_interval = 0.55,
}
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
s := &world.spawner
if s.entry_remaining <= 0 && s.recipe_index >= len(s.recipe) do return
if world.spawner.enemies_to_spawn <= 0 do return
if len(world.path) < 2 do return
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
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
}
}
kind := s.recipe[s.recipe_index].kind
spawn_enemy(world, kind)
s.entry_remaining -= 1
s.spawn_timer = s.spawn_interval
wave_clear :: proc(world: ^World) -> bool {
if world.spawner.enemies_to_spawn > 0 do return false
return active_enemy_count(world) == 0
}
update_phase :: proc(world: ^World) {
if world.phase == .Combat && clear_wave(world) {
if world.phase == .Combat && wave_clear(world) {
world.phase = .Build
push_event(world, .Wave_Survived, gold_reward = 25)
}
@@ -105,7 +56,7 @@ check_end :: proc(world: ^World) {
world.phase = .Game_Over
}
if world.wave >= MAX_WAVES && world.phase == .Build && clear_wave(world) {
if world.wave >= MAX_WAVES && world.phase == .Build && wave_clear(world) {
world.phase = .Victory
}
}
-145
View File
@@ -1,145 +0,0 @@
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)
}
+14 -16
View File
@@ -1,26 +1,24 @@
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,
selected_tower: Tower_Kind,
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,
}
init_world :: proc() -> World {
world := World {
gold = STARTING_GOLD,
base_health = STARTING_BASE_HEALTH,
phase = .Build,
selected_tower = .Archer,
gold = 100,
base_health = 20,
phase = .Build,
}
init_map(&world.world_map)
build_path(&world.world_map, &world.path)