use nightshade_api::prelude::*;
use std::collections::HashMap;
#[derive(Clone, Copy, PartialEq)]
enum TowerKind {
Basic,
Frost,
Cannon,
Sniper,
Poison,
}
impl TowerKind {
const ALL: [TowerKind; 5] = [
TowerKind::Basic,
TowerKind::Frost,
TowerKind::Cannon,
TowerKind::Sniper,
TowerKind::Poison,
];
fn cost(self) -> i32 {
match self {
TowerKind::Basic => 60,
TowerKind::Frost => 120,
TowerKind::Cannon => 200,
TowerKind::Sniper => 180,
TowerKind::Poison => 150,
}
}
fn damage(self) -> f32 {
match self {
TowerKind::Basic => 15.0,
TowerKind::Frost => 8.0,
TowerKind::Cannon => 50.0,
TowerKind::Sniper => 80.0,
TowerKind::Poison => 5.0,
}
}
fn range(self) -> f32 {
match self {
TowerKind::Basic => 3.0,
TowerKind::Frost => 2.5,
TowerKind::Cannon => 4.0,
TowerKind::Sniper => 6.0,
TowerKind::Poison => 2.8,
}
}
fn fire_rate(self) -> f32 {
match self {
TowerKind::Basic => 0.5,
TowerKind::Frost => 1.0,
TowerKind::Cannon => 2.0,
TowerKind::Sniper => 3.0,
TowerKind::Poison => 0.8,
}
}
fn projectile_speed(self) -> f32 {
match self {
TowerKind::Basic => 12.0,
TowerKind::Frost => 8.0,
TowerKind::Cannon => 10.0,
TowerKind::Sniper => 20.0,
TowerKind::Poison => 10.0,
}
}
fn color(self) -> [f32; 4] {
match self {
TowerKind::Basic => [1.0, 0.5, 0.0, 1.0],
TowerKind::Frost => [0.2, 0.6, 1.0, 1.0],
TowerKind::Cannon => [0.8, 0.2, 0.2, 1.0],
TowerKind::Sniper => [0.3, 0.3, 0.3, 1.0],
TowerKind::Poison => [0.6, 0.2, 0.8, 1.0],
}
}
fn name(self) -> &'static str {
match self {
TowerKind::Basic => "Basic",
TowerKind::Frost => "Frost",
TowerKind::Cannon => "Cannon",
TowerKind::Sniper => "Sniper",
TowerKind::Poison => "Poison",
}
}
fn description(self) -> &'static str {
match self {
TowerKind::Basic => "Balanced fire",
TowerKind::Frost => "Slows on hit",
TowerKind::Cannon => "Splash damage",
TowerKind::Sniper => "Charged shot",
TowerKind::Poison => "Damage over time",
}
}
fn hotkey(self) -> &'static str {
match self {
TowerKind::Basic => "1",
TowerKind::Frost => "2",
TowerKind::Cannon => "3",
TowerKind::Sniper => "4",
TowerKind::Poison => "5",
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum EnemyKind {
Normal,
Fast,
Tank,
Flying,
Shielded,
Healer,
Boss,
}
impl EnemyKind {
fn base_health(self) -> f32 {
match self {
EnemyKind::Normal => 50.0,
EnemyKind::Fast => 30.0,
EnemyKind::Tank => 150.0,
EnemyKind::Flying => 40.0,
EnemyKind::Shielded => 60.0,
EnemyKind::Healer => 80.0,
EnemyKind::Boss => 500.0,
}
}
fn health(self, wave: i32) -> f32 {
self.base_health() * (1.0 + (wave as f32 - 1.0) * 0.5)
}
fn speed(self) -> f32 {
match self {
EnemyKind::Normal => 2.0,
EnemyKind::Fast => 4.0,
EnemyKind::Tank => 1.0,
EnemyKind::Flying => 2.5,
EnemyKind::Shielded => 1.5,
EnemyKind::Healer => 1.8,
EnemyKind::Boss => 0.8,
}
}
fn value(self, wave: i32) -> i32 {
let base = match self {
EnemyKind::Normal => 10,
EnemyKind::Fast => 15,
EnemyKind::Tank => 30,
EnemyKind::Flying => 20,
EnemyKind::Shielded => 25,
EnemyKind::Healer => 35,
EnemyKind::Boss => 100,
};
base + wave * 2
}
fn color(self) -> [f32; 4] {
match self {
EnemyKind::Normal => [0.8, 0.2, 0.2, 1.0],
EnemyKind::Fast => [1.0, 0.5, 0.0, 1.0],
EnemyKind::Tank => [0.4, 0.4, 0.4, 1.0],
EnemyKind::Flying => [0.5, 0.8, 1.0, 1.0],
EnemyKind::Shielded => [0.2, 0.6, 0.9, 1.0],
EnemyKind::Healer => [0.2, 0.9, 0.4, 1.0],
EnemyKind::Boss => [0.6, 0.0, 0.6, 1.0],
}
}
fn shield(self) -> f32 {
match self {
EnemyKind::Shielded => 30.0,
EnemyKind::Boss => 100.0,
_ => 0.0,
}
}
fn y_offset(self) -> f32 {
match self {
EnemyKind::Normal => -0.15,
EnemyKind::Fast => -0.05,
EnemyKind::Tank => -0.05,
EnemyKind::Flying => 1.85,
EnemyKind::Shielded => -0.15,
EnemyKind::Healer => -0.2,
EnemyKind::Boss => 0.15,
}
}
}
struct Enemy {
entity: Entity,
kind: EnemyKind,
health: f32,
shield: f32,
speed: f32,
path_index: usize,
progress: f32,
value: i32,
slow: f32,
poison: f32,
}
struct Tower {
entity: Entity,
kind: TowerKind,
cell: (i32, i32),
position: Vec3,
cooldown: f32,
target: Option<Entity>,
tracking: f32,
fire_anim: f32,
}
struct Projectile {
entity: Entity,
kind: TowerKind,
damage: f32,
target: Entity,
speed: f32,
position: Vec3,
start: Vec3,
arc: f32,
flight: f32,
}
struct Popup {
entity: Entity,
age: f32,
}
#[derive(PartialEq)]
enum Phase {
Waiting,
InProgress,
GameOver,
}
struct Hud {
money: Entity,
wave: Entity,
lives: Entity,
hp: Entity,
hp_fill: Entity,
speed: Entity,
speed_minus: Entity,
speed_reset: Entity,
speed_plus: Entity,
chips: Vec<(Entity, TowerKind)>,
announce_root: Entity,
announce_label: Entity,
status_root: Entity,
status_label: Entity,
}
struct Game {
path: Vec<Vec3>,
tiles: HashMap<(i32, i32), Entity>,
tile_colors: HashMap<(i32, i32), [f32; 4]>,
buildable: HashMap<(i32, i32), bool>,
last_hover: Option<(i32, i32)>,
enemies: Vec<Enemy>,
towers: Vec<Tower>,
projectiles: Vec<Projectile>,
popups: Vec<Popup>,
money: i32,
lives: i32,
wave: i32,
current_hp: i32,
max_hp: i32,
phase: Phase,
selected: TowerKind,
queue: Vec<(EnemyKind, f32)>,
spawn_timer: f32,
wave_delay: f32,
announce_timer: f32,
speed: f32,
rng: u64,
hud: Hud,
ghost: Entity,
sell_label: Entity,
}
const HP_BAR_WIDTH: f32 = 220.0;
const PANEL_BG: [f32; 4] = [0.05, 0.06, 0.09, 0.92];
const PANEL_BG_DEEP: [f32; 4] = [0.03, 0.04, 0.07, 0.96];
const TEXT_COLOR: [f32; 4] = [0.92, 0.94, 1.0, 1.0];
const TEXT_DIM: [f32; 4] = [0.6, 0.66, 0.78, 1.0];
const TEXT_FAINT: [f32; 4] = [0.42, 0.48, 0.6, 1.0];
const GOLD: [f32; 4] = [1.0, 0.86, 0.35, 1.0];
const GREEN_ACCENT: [f32; 4] = [0.35, 0.95, 0.45, 1.0];
const BLUE_ACCENT: [f32; 4] = [0.45, 0.8, 1.0, 1.0];
const HP_TRACK: [f32; 4] = [0.08, 0.1, 0.14, 1.0];
fn waypoints() -> Vec<Vec3> {
[
(-6.0, 0.0),
(-3.0, 0.0),
(-3.0, -4.0),
(3.0, -4.0),
(3.0, 2.0),
(-1.0, 2.0),
(-1.0, 5.0),
(6.0, 5.0),
]
.iter()
.map(|(x, z)| vec3(*x, 0.0, *z))
.collect()
}
fn next_rand(game: &mut Game) -> f32 {
let mut value = game.rng;
value ^= value << 13;
value ^= value >> 7;
value ^= value << 17;
game.rng = value;
(value >> 40) as f32 / (1u64 << 24) as f32
}
fn child(
world: &mut World,
parent: Entity,
shape: Shape,
offset: Vec3,
scale: Vec3,
color: [f32; 4],
emissive: f32,
) {
let entity = spawn_object(
world,
Object {
shape,
position: Vec3::zeros(),
scale,
color,
body: Body::None,
},
);
set_parent(world, entity, Some(parent));
set_position(world, entity, offset);
if emissive > 0.0 {
set_emissive(world, entity, [color[0], color[1], color[2]], emissive);
}
if color[3] < 1.0 {
set_alpha_blend(world, entity, true);
}
}
fn spawn_enemy_model(world: &mut World, kind: EnemyKind, position: Vec3) -> Entity {
let color = kind.color();
let dim = [color[0] * 0.85, color[1] * 0.85, color[2] * 0.85, 1.0];
let body = match kind {
EnemyKind::Normal => Shape::Cube,
EnemyKind::Fast => Shape::Cone,
EnemyKind::Flying => Shape::Sphere,
EnemyKind::Healer => Shape::Torus,
_ => Shape::Cube,
};
let scale = match kind {
EnemyKind::Normal => vec3(0.5, 0.6, 0.4),
EnemyKind::Fast => vec3(0.44, 0.8, 0.44),
EnemyKind::Tank => vec3(0.7, 0.8, 0.6),
EnemyKind::Flying => vec3(0.44, 0.56, 0.44),
EnemyKind::Shielded => vec3(0.5, 0.6, 0.4),
EnemyKind::Healer => vec3(0.5, 0.5, 0.5),
EnemyKind::Boss => vec3(1.0, 1.2, 0.8),
};
let main = spawn_object(
world,
Object {
shape: body,
position,
scale,
color,
body: Body::None,
},
);
if matches!(kind, EnemyKind::Flying | EnemyKind::Healer) {
set_emissive(
world,
main,
[color[0] * 0.4, color[1] * 0.4, color[2] * 0.4],
1.0,
);
}
match kind {
EnemyKind::Normal => {
child(
world,
main,
Shape::Sphere,
vec3(0.0, 0.5, 0.0),
vec3(0.3, 0.3, 0.3),
dim,
0.0,
);
child(
world,
main,
Shape::Cube,
vec3(-0.4, -0.1, 0.0),
vec3(0.16, 0.4, 0.16),
dim,
0.0,
);
child(
world,
main,
Shape::Cube,
vec3(0.4, -0.1, 0.0),
vec3(0.16, 0.4, 0.16),
dim,
0.0,
);
}
EnemyKind::Fast => {
child(
world,
main,
Shape::Sphere,
vec3(0.0, 0.6, 0.0),
vec3(0.26, 0.26, 0.26),
color,
0.6,
);
}
EnemyKind::Tank => {
child(
world,
main,
Shape::Cube,
vec3(0.0, 0.6, 0.0),
vec3(0.5, 0.5, 0.5),
dim,
0.0,
);
child(
world,
main,
Shape::Cube,
vec3(-0.5, 0.3, 0.0),
vec3(0.3, 0.3, 0.3),
color,
0.0,
);
child(
world,
main,
Shape::Cube,
vec3(0.5, 0.3, 0.0),
vec3(0.3, 0.3, 0.3),
color,
0.0,
);
}
EnemyKind::Flying => {
child(
world,
main,
Shape::Cone,
vec3(-0.6, 0.0, 0.0),
vec3(0.36, 0.2, 0.36),
[color[0] * 0.5, color[1] * 0.5, color[2] * 0.5, 0.7],
0.0,
);
child(
world,
main,
Shape::Cone,
vec3(0.6, 0.0, 0.0),
vec3(0.36, 0.2, 0.36),
[color[0] * 0.5, color[1] * 0.5, color[2] * 0.5, 0.7],
0.0,
);
}
EnemyKind::Shielded => {
child(
world,
main,
Shape::Sphere,
vec3(0.0, 0.5, 0.0),
vec3(0.3, 0.3, 0.3),
dim,
0.0,
);
child(
world,
main,
Shape::Cylinder,
vec3(0.45, 0.0, 0.0),
vec3(0.6, 0.12, 0.6),
[0.7, 0.7, 1.0, 0.6],
0.4,
);
}
EnemyKind::Healer => {
child(
world,
main,
Shape::Sphere,
vec3(0.0, 0.0, 0.0),
vec3(0.32, 0.32, 0.32),
[0.3, 1.0, 0.3, 1.0],
1.5,
);
}
EnemyKind::Boss => {
child(
world,
main,
Shape::Cube,
vec3(0.0, 0.8, 0.0),
vec3(0.7, 0.7, 0.7),
dim,
0.0,
);
child(
world,
main,
Shape::Cone,
vec3(0.0, 1.3, 0.0),
vec3(0.5, 0.5, 0.5),
[1.0, 0.85, 0.0, 1.0],
1.0,
);
child(
world,
main,
Shape::Cube,
vec3(-0.7, 0.4, 0.0),
vec3(0.35, 0.45, 0.35),
color,
0.0,
);
child(
world,
main,
Shape::Cube,
vec3(0.7, 0.4, 0.0),
vec3(0.35, 0.45, 0.35),
color,
0.0,
);
}
}
main
}
fn build_hud(world: &mut World, selected: TowerKind) -> Hud {
let credits = spawn_panel_at(
world,
ScreenAnchor::TopLeft,
vec2(20.0, 20.0),
vec2(240.0, 110.0),
PANEL_BG,
);
panel_text(
world,
credits,
"CREDITS",
[14.0, 12.0, 210.0, 14.0],
11.0,
TEXT_DIM,
TextAlignment::Left,
);
let money = panel_text(
world,
credits,
"$200",
[14.0, 30.0, 210.0, 30.0],
26.0,
GOLD,
TextAlignment::Left,
);
panel_text(
world,
credits,
"WAVE",
[14.0, 66.0, 210.0, 12.0],
10.0,
TEXT_FAINT,
TextAlignment::Left,
);
let wave = panel_text(
world,
credits,
"0",
[14.0, 80.0, 210.0, 18.0],
16.0,
TEXT_COLOR,
TextAlignment::Left,
);
let status = spawn_panel_at(
world,
ScreenAnchor::TopRight,
vec2(-20.0, 20.0),
vec2(260.0, 110.0),
PANEL_BG,
);
panel_text(
world,
status,
"LIVES",
[14.0, 12.0, 232.0, 12.0],
10.0,
TEXT_FAINT,
TextAlignment::Left,
);
let lives = panel_text(
world,
status,
"x20",
[14.0, 26.0, 232.0, 24.0],
22.0,
GREEN_ACCENT,
TextAlignment::Left,
);
let hp = panel_text(
world,
status,
"HP 20/20",
[14.0, 56.0, 232.0, 14.0],
11.0,
TEXT_DIM,
TextAlignment::Left,
);
panel_box(
world,
status,
vec2(14.0, 78.0),
vec2(HP_BAR_WIDTH, 12.0),
HP_TRACK,
);
let hp_fill = panel_box(
world,
status,
vec2(14.0, 78.0),
vec2(HP_BAR_WIDTH, 12.0),
GREEN_ACCENT,
);
let speed_panel = spawn_panel_at(
world,
ScreenAnchor::TopRight,
vec2(-20.0, 144.0),
vec2(260.0, 56.0),
PANEL_BG_DEEP,
);
panel_text(
world,
speed_panel,
"SPEED",
[12.0, 8.0, 54.0, 18.0],
10.0,
TEXT_FAINT,
TextAlignment::Left,
);
let speed = panel_text(
world,
speed_panel,
"1.0x",
[12.0, 26.0, 54.0, 22.0],
18.0,
BLUE_ACCENT,
TextAlignment::Left,
);
let speed_minus = panel_button_at(world, speed_panel, "-", vec2(120.0, 14.0), vec2(34.0, 28.0));
let speed_reset = panel_button_at(
world,
speed_panel,
"1x",
vec2(160.0, 14.0),
vec2(34.0, 28.0),
);
let speed_plus = panel_button_at(world, speed_panel, "+", vec2(200.0, 14.0), vec2(34.0, 28.0));
let announce_root = spawn_panel_at(
world,
ScreenAnchor::TopCenter,
vec2(0.0, 32.0),
vec2(420.0, 84.0),
PANEL_BG_DEEP,
);
let announce_label = panel_text(
world,
announce_root,
"WAVE 1",
[10.0, 12.0, 400.0, 60.0],
36.0,
GOLD,
TextAlignment::Center,
);
set_panel_visible(world, announce_root, false);
let status_root = spawn_panel_at(
world,
ScreenAnchor::Center,
vec2(0.0, 0.0),
vec2(520.0, 120.0),
PANEL_BG_DEEP,
);
let status_label = panel_text(
world,
status_root,
"GAME OVER",
[10.0, 30.0, 500.0, 60.0],
48.0,
TEXT_COLOR,
TextAlignment::Center,
);
set_panel_visible(world, status_root, false);
let tower_panel = spawn_panel_at(
world,
ScreenAnchor::BottomCenter,
vec2(0.0, -100.0),
vec2(720.0, 90.0),
PANEL_BG,
);
let mut chips = Vec::new();
for (index, kind) in TowerKind::ALL.iter().enumerate() {
let x = 12.0 + index as f32 * 142.0;
let chip = panel_button_at(world, tower_panel, "", vec2(x, 12.0), vec2(132.0, 64.0));
let accent = kind.color();
panel_box(world, chip, vec2(8.0, 6.0), vec2(18.0, 18.0), accent);
panel_text(
world,
chip,
kind.hotkey(),
[8.0, 6.0, 18.0, 18.0],
12.0,
TEXT_COLOR,
TextAlignment::Center,
);
panel_text(
world,
chip,
kind.name(),
[32.0, 6.0, 94.0, 20.0],
16.0,
TEXT_COLOR,
TextAlignment::Left,
);
panel_text(
world,
chip,
&format!("${}", kind.cost()),
[32.0, 26.0, 94.0, 14.0],
12.0,
GOLD,
TextAlignment::Left,
);
panel_text(
world,
chip,
kind.description(),
[8.0, 44.0, 118.0, 14.0],
10.0,
TEXT_DIM,
TextAlignment::Left,
);
set_panel_selected(
world,
chip,
*kind == selected,
[accent[0], accent[1], accent[2], 0.35],
);
chips.push((chip, *kind));
}
spawn_text(
world,
"left click place / right click sell / scroll or 1-5 select / [ ] speed / C reset view",
ScreenAnchor::BottomCenter,
);
Hud {
money,
wave,
lives,
hp,
hp_fill,
speed,
speed_minus,
speed_reset,
speed_plus,
chips,
announce_root,
announce_label,
status_root,
status_label,
}
}
fn path_cells(path: &[Vec3]) -> std::collections::HashSet<(i32, i32)> {
let mut cells = std::collections::HashSet::new();
for pair in path.windows(2) {
for step in 0..=20 {
let t = step as f32 / 20.0;
let point = pair[0] + (pair[1] - pair[0]) * t;
cells.insert((point.x.round() as i32, point.z.round() as i32));
}
}
cells
}
fn main() {
run(
|world| {
set_window_title(world, "Tower Defense");
set_background(world, Background::Nebula);
set_bloom(world, true);
show_grid(world, false);
let path = waypoints();
orbit_camera(world, vec3(0.0, 0.0, 0.0), 16.1);
set_orbit_view(world, vec3(0.0, 0.0, 0.0), 16.1, 0.0, 0.52);
set_orbit_zoom(world, false);
let cells = path_cells(&path);
let start = (path[0].x.round() as i32, path[0].z.round() as i32);
let end = (
path.last().unwrap().x.round() as i32,
path.last().unwrap().z.round() as i32,
);
let mut tiles = HashMap::new();
let mut tile_colors = HashMap::new();
let mut buildable = HashMap::new();
for x in -6..=6 {
for z in -6..=6 {
let near_start = (x - start.0).abs() <= 1 && (z - start.1).abs() <= 1;
let near_end = (x - end.0).abs() <= 1 && (z - end.1).abs() <= 1;
if near_start || near_end {
continue;
}
let is_path = cells.contains(&(x, z));
let color = if is_path {
[0.5, 0.3, 0.1, 1.0]
} else {
[0.1, 0.3, 0.1, 1.0]
};
let tile = spawn_object(
world,
Object {
shape: Shape::Cube,
position: vec3(x as f32, -0.5, z as f32),
scale: vec3(0.9, 0.1, 0.9),
color,
body: Body::None,
},
);
tiles.insert((x, z), tile);
tile_colors.insert((x, z), color);
buildable.insert((x, z), !is_path);
}
}
let start_marker = spawn_object(
world,
Object {
shape: Shape::Cube,
position: path[0],
scale: vec3(1.5, 1.0, 1.5),
color: [1.0, 0.5, 0.0, 1.0],
body: Body::None,
},
);
set_emissive(world, start_marker, [0.5, 0.25, 0.0], 1.0);
let end_marker = spawn_object(
world,
Object {
shape: Shape::Cube,
position: *path.last().unwrap() + vec3(0.0, 0.25, 0.0),
scale: vec3(2.0, 1.5, 2.0),
color: [0.2, 0.2, 0.8, 1.0],
body: Body::None,
},
);
set_emissive(world, end_marker, [0.1, 0.1, 0.4], 1.0);
let ghost = spawn_object(
world,
Object {
shape: Shape::Cylinder,
position: vec3(0.0, -100.0, 0.0),
scale: vec3(0.4, 0.8, 0.4),
color: [1.0, 1.0, 1.0, 0.4],
body: Body::None,
},
);
set_alpha_blend(world, ghost, true);
let sell_label = spawn_label(world, "", vec3(0.0, -100.0, 0.0));
set_text_color(world, sell_label, [1.0, 1.0, 0.0, 1.0]);
set_text_outline(world, sell_label, 0.08, [0.0, 0.0, 0.0, 1.0]);
let hud = build_hud(world, TowerKind::Basic);
Game {
path,
tiles,
tile_colors,
buildable,
last_hover: None,
enemies: Vec::new(),
towers: Vec::new(),
projectiles: Vec::new(),
popups: Vec::new(),
money: 200,
lives: 20,
wave: 0,
current_hp: 20,
max_hp: 20,
phase: Phase::Waiting,
selected: TowerKind::Basic,
queue: Vec::new(),
spawn_timer: 0.0,
wave_delay: 3.0,
announce_timer: 0.0,
speed: 1.0,
rng: 0x2545_f491_4f6c_dd1d,
hud,
ghost,
sell_label,
}
},
update,
)
.unwrap();
}
fn update(world: &mut World, game: &mut Game) {
handle_keys(world, game);
handle_ui(world, game);
let delta = delta_time(world) * game.speed;
if game.announce_timer > 0.0 {
game.announce_timer -= delta_time(world);
}
if game.phase == Phase::Waiting {
game.wave_delay -= delta;
if game.wave_delay <= 0.0 {
game.rng ^= uptime_milliseconds(world) | 1;
plan_wave(game);
}
} else if game.phase == Phase::InProgress {
spawn_from_queue(world, game, delta);
}
move_enemies(world, game, delta);
target_towers(world, game, delta);
shoot_towers(world, game, delta);
move_projectiles(world, game, delta);
update_popups(world, game, delta_time(world));
if game.phase == Phase::InProgress && game.queue.is_empty() && game.enemies.is_empty() {
game.phase = Phase::Waiting;
game.wave_delay = 3.0;
}
handle_mouse(world, game);
update_hud(world, game);
}
fn plan_wave(game: &mut Game) {
game.wave += 1;
let wave = game.wave;
let boss_wave = wave % 5 == 0;
let count = 5 + wave * 2;
let table: Vec<(EnemyKind, f32)> = if boss_wave {
match wave {
5 => vec![
(EnemyKind::Normal, 0.3),
(EnemyKind::Fast, 0.3),
(EnemyKind::Tank, 0.2),
(EnemyKind::Boss, 0.2),
],
10 => vec![
(EnemyKind::Normal, 0.25),
(EnemyKind::Fast, 0.25),
(EnemyKind::Tank, 0.15),
(EnemyKind::Flying, 0.15),
(EnemyKind::Boss, 0.2),
],
15 => vec![
(EnemyKind::Normal, 0.2),
(EnemyKind::Fast, 0.2),
(EnemyKind::Tank, 0.15),
(EnemyKind::Flying, 0.15),
(EnemyKind::Shielded, 0.1),
(EnemyKind::Boss, 0.2),
],
_ => vec![
(EnemyKind::Normal, 0.15),
(EnemyKind::Fast, 0.15),
(EnemyKind::Tank, 0.15),
(EnemyKind::Flying, 0.15),
(EnemyKind::Shielded, 0.1),
(EnemyKind::Healer, 0.1),
(EnemyKind::Boss, 0.2),
],
}
} else {
match wave {
1..=2 => vec![(EnemyKind::Normal, 1.0)],
3..=4 => vec![(EnemyKind::Normal, 0.7), (EnemyKind::Fast, 0.3)],
6..=9 => vec![
(EnemyKind::Normal, 0.4),
(EnemyKind::Fast, 0.3),
(EnemyKind::Tank, 0.2),
(EnemyKind::Flying, 0.1),
],
11..=14 => vec![
(EnemyKind::Normal, 0.3),
(EnemyKind::Fast, 0.25),
(EnemyKind::Tank, 0.2),
(EnemyKind::Flying, 0.15),
(EnemyKind::Shielded, 0.1),
],
_ => vec![
(EnemyKind::Normal, 0.25),
(EnemyKind::Fast, 0.2),
(EnemyKind::Tank, 0.2),
(EnemyKind::Flying, 0.15),
(EnemyKind::Shielded, 0.1),
(EnemyKind::Healer, 0.1),
],
}
};
let interval = match wave {
1..=3 => 1.0,
4..=6 => 0.8,
_ => 0.6,
};
let mut time = 0.0;
let mut queue = Vec::new();
for _ in 0..count {
let roll = next_rand(game);
let mut cumulative = 0.0;
let mut chosen = EnemyKind::Normal;
for (kind, probability) in &table {
cumulative += probability;
if roll < cumulative {
chosen = *kind;
break;
}
}
queue.push((chosen, time));
time += interval;
}
game.queue = queue;
game.spawn_timer = 0.0;
game.phase = Phase::InProgress;
game.announce_timer = if boss_wave { 3.0 } else { 2.0 };
}
fn spawn_from_queue(world: &mut World, game: &mut Game, delta: f32) {
game.spawn_timer += delta;
let ready: Vec<EnemyKind> = game
.queue
.iter()
.filter(|(_, time)| *time <= game.spawn_timer)
.map(|(kind, _)| *kind)
.collect();
game.queue.retain(|(_, time)| *time > game.spawn_timer);
for kind in ready {
let entity = spawn_enemy_model(world, kind, game.path[0] + vec3(0.0, kind.y_offset(), 0.0));
game.enemies.push(Enemy {
entity,
kind,
health: kind.health(game.wave),
shield: kind.shield(),
speed: kind.speed(),
path_index: 0,
progress: 0.0,
value: kind.value(game.wave),
slow: 0.0,
poison: 0.0,
});
}
}
fn enemy_at(path: &[Vec3], enemy: &Enemy) -> Vec3 {
let start = path[enemy.path_index];
let end = path[(enemy.path_index + 1).min(path.len() - 1)];
start + (end - start) * enemy.progress
}
fn move_enemies(world: &mut World, game: &mut Game, delta: f32) {
let path = game.path.clone();
let mut leaked = Vec::new();
let mut killed = Vec::new();
for index in 0..game.enemies.len() {
let speed = {
let enemy = &mut game.enemies[index];
if enemy.slow > 0.0 {
enemy.slow -= delta;
}
if enemy.poison > 0.0 {
enemy.poison -= delta;
enemy.health -= 2.0 * delta;
}
if enemy.health <= 0.0 {
killed.push(index);
continue;
}
if enemy.slow > 0.0 {
enemy.speed * 0.5
} else {
enemy.speed
}
};
let enemy = &mut game.enemies[index];
let start = game.path[enemy.path_index];
let end = game.path[enemy.path_index + 1];
let length = (end - start).magnitude();
enemy.progress += (speed * delta) / length;
while enemy.progress >= 1.0 && enemy.path_index < game.path.len() - 2 {
enemy.progress -= 1.0;
enemy.path_index += 1;
}
if enemy.path_index >= game.path.len() - 2 && enemy.progress >= 1.0 {
leaked.push(index);
continue;
}
let kind = enemy.kind;
let position = enemy_at(&path, &game.enemies[index]);
set_position(
world,
game.enemies[index].entity,
position + vec3(0.0, kind.y_offset(), 0.0),
);
}
for &index in killed.iter().rev() {
let enemy = game.enemies.remove(index);
let position = enemy_at(&path, &enemy);
reward(
world,
game,
enemy.value,
position + vec3(0.0, enemy.kind.y_offset(), 0.0),
);
despawn(world, enemy.entity);
}
for &index in leaked.iter().rev() {
let enemy = game.enemies.remove(index);
despawn(world, enemy.entity);
game.current_hp -= 1;
if game.current_hp <= 0 {
game.current_hp = game.max_hp;
game.lives -= 1;
if game.lives <= 0 {
game.phase = Phase::GameOver;
set_panel_text(world, game.hud.status_label, "GAME OVER");
set_panel_visible(world, game.hud.status_root, true);
}
}
}
}
fn reward(world: &mut World, game: &mut Game, value: i32, position: Vec3) {
game.money += value;
emit_burst(world, position, [0.5, 0.5, 0.5, 0.8], 20);
spawn_popup(world, game, position, value);
}
fn spawn_popup(world: &mut World, game: &mut Game, position: Vec3, amount: i32) {
let label = spawn_label(world, &format!("+{amount}"), position + vec3(0.0, 0.5, 0.0));
set_text_color(world, label, [1.0, 0.9, 0.3, 1.0]);
set_text_size(world, label, 28.0);
game.popups.push(Popup {
entity: label,
age: 0.0,
});
}
fn update_popups(world: &mut World, game: &mut Game, delta: f32) {
let mut done = Vec::new();
for index in 0..game.popups.len() {
game.popups[index].age += delta;
let age = game.popups[index].age;
if age >= 1.0 {
done.push(index);
continue;
}
let entity = game.popups[index].entity;
let new_position = position(world, entity) + vec3(0.0, delta * 1.5, 0.0);
set_position(world, entity, new_position);
set_text_color(world, entity, [1.0, 0.9, 0.3, 1.0 - age]);
}
for &index in done.iter().rev() {
despawn(world, game.popups.remove(index).entity);
}
}
fn target_towers(world: &mut World, game: &mut Game, delta: f32) {
let path = game.path.clone();
for tower in game.towers.iter_mut() {
let valid = tower
.target
.map(|target| game.enemies.iter().any(|enemy| enemy.entity == target))
.unwrap_or(false);
if !valid {
tower.target = None;
tower.tracking = 0.0;
}
if tower.target.is_none() {
let mut best: Option<(Entity, f32)> = None;
for enemy in &game.enemies {
let distance = (enemy_at(&path, enemy) - tower.position).magnitude();
if distance <= tower.kind.range() && best.map(|(_, d)| distance < d).unwrap_or(true)
{
best = Some((enemy.entity, distance));
}
}
tower.target = best.map(|(entity, _)| entity);
} else if tower.kind == TowerKind::Sniper {
tower.tracking += delta;
if let Some(target) = tower.target
&& let Some(enemy) = game.enemies.iter().find(|enemy| enemy.entity == target)
{
let from = tower.position + vec3(0.0, 0.5, 0.0);
let to = enemy_at(&path, enemy) + vec3(0.0, 0.3, 0.0);
draw_line(world, from, to, [1.0, 0.0, 0.0, 0.8]);
}
}
}
}
fn shoot_towers(world: &mut World, game: &mut Game, delta: f32) {
let mut shots = Vec::new();
for tower in game.towers.iter_mut() {
tower.cooldown = (tower.cooldown - delta).max(0.0);
if tower.fire_anim > 0.0 {
tower.fire_anim = (tower.fire_anim - delta * 3.0).max(0.0);
}
if tower.cooldown > 0.0 {
continue;
}
let Some(target) = tower.target else { continue };
if tower.kind == TowerKind::Sniper && tower.tracking < 2.0 {
continue;
}
if !game.enemies.iter().any(|enemy| enemy.entity == target) {
continue;
}
tower.cooldown = tower.kind.fire_rate();
tower.fire_anim = 1.0;
shots.push((tower.position, tower.kind, target));
}
for (position, kind, target) in shots {
let start = position + vec3(0.0, 0.5, 0.0);
let entity = spawn_object(
world,
Object {
shape: Shape::Sphere,
position: start,
scale: vec3(0.15, 0.15, 0.15),
color: kind.color(),
body: Body::None,
},
);
set_emissive(
world,
entity,
[
kind.color()[0] * 0.6,
kind.color()[1] * 0.6,
kind.color()[2] * 0.6,
],
1.0,
);
if kind == TowerKind::Cannon {
emit_burst(world, start, [1.0, 0.9, 0.3, 1.0], 12);
}
game.projectiles.push(Projectile {
entity,
kind,
damage: kind.damage(),
target,
speed: kind.projectile_speed(),
position: start,
start,
arc: if kind == TowerKind::Cannon { 2.0 } else { 0.0 },
flight: 0.0,
});
}
for tower in game.towers.iter() {
let pulse = 1.0 + tower.fire_anim * 0.2;
set_scale(world, tower.entity, vec3(0.4 * pulse, 0.8, 0.4 * pulse));
let base = tower.kind.color();
let flash = tower.fire_anim;
set_emissive(
world,
tower.entity,
[
base[0] * 0.4 + flash,
base[1] * 0.4 + flash,
base[2] * 0.4 + flash,
],
1.0,
);
}
}
fn move_projectiles(world: &mut World, game: &mut Game, delta: f32) {
let path = game.path.clone();
let mut done = Vec::new();
let mut hits: Vec<(Entity, TowerKind, f32, Vec3)> = Vec::new();
for index in 0..game.projectiles.len() {
let target = game.projectiles[index].target;
let Some(enemy) = game.enemies.iter().find(|enemy| enemy.entity == target) else {
done.push(index);
continue;
};
let target_position = enemy_at(&path, enemy) + vec3(0.0, enemy.kind.y_offset(), 0.0);
let projectile = &mut game.projectiles[index];
let new_position = if projectile.arc > 0.0 {
let total = (target_position - projectile.start).magnitude().max(0.01);
projectile.flight = (projectile.flight + projectile.speed * delta / total).min(1.0);
let base = projectile.start + (target_position - projectile.start) * projectile.flight;
base + vec3(
0.0,
4.0 * projectile.arc * projectile.flight * (1.0 - projectile.flight),
0.0,
)
} else {
let direction = (target_position - projectile.position).normalize();
projectile.position + direction * projectile.speed * delta
};
projectile.position = new_position;
set_position(world, projectile.entity, new_position);
let reached = (target_position - new_position).magnitude() < 0.3
|| (projectile.arc > 0.0 && projectile.flight >= 1.0);
if reached {
hits.push((target, projectile.kind, projectile.damage, target_position));
done.push(index);
}
}
for &index in done.iter().rev() {
despawn(world, game.projectiles.remove(index).entity);
}
for (target, kind, damage, position) in hits {
if matches!(kind, TowerKind::Cannon | TowerKind::Sniper) {
emit_burst(world, position, [1.0, 0.5, 0.0, 1.0], 8);
}
apply_damage(world, game, target, kind, damage, position);
}
}
fn apply_damage(
world: &mut World,
game: &mut Game,
target: Entity,
kind: TowerKind,
damage: f32,
position: Vec3,
) {
let Some(index) = game.enemies.iter().position(|enemy| enemy.entity == target) else {
return;
};
let enemy = &mut game.enemies[index];
let mut remaining = damage;
if enemy.shield > 0.0 {
let absorbed = remaining.min(enemy.shield);
enemy.shield -= absorbed;
remaining -= absorbed;
}
enemy.health -= remaining;
if kind == TowerKind::Frost {
enemy.slow = 2.0;
}
if kind == TowerKind::Poison {
enemy.poison = 3.0;
emit_burst(world, position, [0.5, 0.0, 0.8, 0.6], 5);
}
if enemy.health <= 0.0 {
let value = enemy.value;
let kind = enemy.kind;
let entity = enemy.entity;
game.enemies.remove(index);
reward(
world,
game,
value,
position + vec3(0.0, kind.y_offset(), 0.0),
);
despawn(world, entity);
}
}
fn grid_under_cursor(world: &World) -> Option<(i32, i32)> {
cursor_on_ground(world).map(|point| (point.x.round() as i32, point.z.round() as i32))
}
fn handle_mouse(world: &mut World, game: &mut Game) {
if let Some(cell) = game.last_hover.take()
&& let Some(color) = game.tile_colors.get(&cell).copied()
&& let Some(&tile) = game.tiles.get(&cell)
{
set_color(world, tile, color);
}
set_position(world, game.ghost, vec3(0.0, -100.0, 0.0));
set_position(world, game.sell_label, vec3(0.0, -100.0, 0.0));
if game.phase == Phase::GameOver || pointer_over_ui(world) {
return;
}
let Some(cell) = grid_under_cursor(world) else {
return;
};
let has_tower = game.towers.iter().any(|tower| tower.cell == cell);
let buildable = game.buildable.get(&cell).copied().unwrap_or(false);
if has_tower {
if let Some(tower) = game.towers.iter().find(|tower| tower.cell == cell) {
let refund = (tower.kind.cost() as f32 * 0.7) as i32;
set_text(world, game.sell_label, &format!("${refund}"));
set_position(
world,
game.sell_label,
vec3(cell.0 as f32, 2.0, cell.1 as f32),
);
}
} else if buildable {
if let Some(&tile) = game.tiles.get(&cell) {
set_color(world, tile, [1.0, 1.0, 0.0, 1.0]);
game.last_hover = Some(cell);
}
let color = game.selected.color();
set_color(world, game.ghost, [color[0], color[1], color[2], 0.4]);
set_position(world, game.ghost, vec3(cell.0 as f32, 0.0, cell.1 as f32));
draw_range(
world,
vec3(cell.0 as f32, 0.1, cell.1 as f32),
game.selected.range(),
color,
);
}
if mouse_clicked(world, MouseButton::Left)
&& buildable
&& !has_tower
&& game.money >= game.selected.cost()
{
place_tower(world, game, cell);
}
if mouse_clicked(world, MouseButton::Right)
&& let Some(index) = game.towers.iter().position(|tower| tower.cell == cell)
{
let tower = game.towers.remove(index);
let refund = (tower.kind.cost() as f32 * 0.7) as i32;
game.money += refund;
spawn_popup(world, game, vec3(cell.0 as f32, 0.5, cell.1 as f32), refund);
despawn(world, tower.entity);
}
}
fn place_tower(world: &mut World, game: &mut Game, cell: (i32, i32)) {
let kind = game.selected;
let position = vec3(cell.0 as f32, 0.0, cell.1 as f32);
let entity = spawn_object(
world,
Object {
shape: Shape::Cylinder,
position,
scale: vec3(0.4, 0.8, 0.4),
color: kind.color(),
body: Body::None,
},
);
set_emissive(
world,
entity,
[
kind.color()[0] * 0.4,
kind.color()[1] * 0.4,
kind.color()[2] * 0.4,
],
1.0,
);
game.money -= kind.cost();
game.buildable.insert(cell, false);
spawn_popup(
world,
game,
vec3(cell.0 as f32, 0.5, cell.1 as f32),
-kind.cost(),
);
game.towers.push(Tower {
entity,
kind,
cell,
position,
cooldown: 0.0,
target: None,
tracking: 0.0,
fire_anim: 0.0,
});
}
fn draw_range(world: &mut World, center: Vec3, range: f32, color: [f32; 4]) {
let ring = [color[0], color[1], color[2], 0.5];
let segments = 32;
for index in 0..segments {
let a = index as f32 / segments as f32 * std::f32::consts::TAU;
let b = (index + 1) as f32 / segments as f32 * std::f32::consts::TAU;
let start = center + vec3(a.cos() * range, 0.0, a.sin() * range);
let end = center + vec3(b.cos() * range, 0.0, b.sin() * range);
draw_line(world, start, end, ring);
}
}
fn handle_keys(world: &mut World, game: &mut Game) {
if key_pressed(world, KeyCode::Digit1) {
game.selected = TowerKind::Basic;
} else if key_pressed(world, KeyCode::Digit2) {
game.selected = TowerKind::Frost;
} else if key_pressed(world, KeyCode::Digit3) {
game.selected = TowerKind::Cannon;
} else if key_pressed(world, KeyCode::Digit4) {
game.selected = TowerKind::Sniper;
} else if key_pressed(world, KeyCode::Digit5) {
game.selected = TowerKind::Poison;
}
if key_pressed(world, KeyCode::BracketLeft) {
game.speed = (game.speed - 0.5).max(0.5);
} else if key_pressed(world, KeyCode::BracketRight) {
game.speed = (game.speed + 0.5).min(3.0);
} else if key_pressed(world, KeyCode::Backslash) {
game.speed = 1.0;
}
if key_pressed(world, KeyCode::KeyC) || key_pressed(world, KeyCode::Home) {
set_orbit_view(world, vec3(0.0, 0.0, 0.0), 16.1, 0.0, 0.52);
}
let scroll = mouse_scroll(world);
if scroll != 0.0 {
let current = TowerKind::ALL
.iter()
.position(|kind| *kind == game.selected)
.unwrap_or(0);
let count = TowerKind::ALL.len();
let next = if scroll > 0.0 {
(current + count - 1) % count
} else {
(current + 1) % count
};
game.selected = TowerKind::ALL[next];
}
}
fn handle_ui(world: &mut World, game: &mut Game) {
let chips = game.hud.chips.clone();
for (chip, kind) in chips {
if button_clicked(world, chip) {
game.selected = kind;
}
}
if button_clicked(world, game.hud.speed_minus) {
game.speed = (game.speed - 0.5).max(0.5);
}
if button_clicked(world, game.hud.speed_reset) {
game.speed = 1.0;
}
if button_clicked(world, game.hud.speed_plus) {
game.speed = (game.speed + 0.5).min(3.0);
}
}
fn update_hud(world: &mut World, game: &mut Game) {
set_panel_text(world, game.hud.money, &format!("${}", game.money));
set_panel_text(world, game.hud.wave, &format!("{}", game.wave));
set_panel_text(world, game.hud.lives, &format!("x{}", game.lives));
set_panel_text(
world,
game.hud.hp,
&format!("HP {}/{}", game.current_hp.max(0), game.max_hp),
);
set_panel_text(world, game.hud.speed, &format!("{:.1}x", game.speed));
let ratio = (game.current_hp.max(0) as f32 / game.max_hp as f32).clamp(0.0, 1.0);
set_panel_rect(
world,
game.hud.hp_fill,
vec2(14.0, 78.0),
vec2(HP_BAR_WIDTH * ratio, 12.0),
);
let hp_color = if ratio > 0.5 {
[0.25, 0.85, 0.35, 1.0]
} else if ratio > 0.25 {
[1.0, 0.78, 0.2, 1.0]
} else {
[0.95, 0.32, 0.32, 1.0]
};
set_panel_color(world, game.hud.hp_fill, hp_color);
let lives_color = if game.lives > 5 {
GREEN_ACCENT
} else if game.lives > 2 {
GOLD
} else {
[0.95, 0.35, 0.35, 1.0]
};
set_panel_text_color(world, game.hud.lives, lives_color);
for (chip, kind) in game.hud.chips.clone() {
let accent = kind.color();
set_panel_selected(
world,
chip,
kind == game.selected,
[accent[0], accent[1], accent[2], 0.35],
);
}
let announce = game.announce_timer > 0.0;
set_panel_visible(world, game.hud.announce_root, announce);
if announce {
let label = if game.wave % 5 == 0 {
format!("BOSS WAVE {}", game.wave)
} else {
format!("WAVE {}", game.wave)
};
set_panel_text(world, game.hud.announce_label, &label);
}
}