use nightshade::ecs::script::components::GlobalScript;
use nightshade_api::prelude::*;
struct Scripted {
runtime: ScriptRuntime,
}
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()
}
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_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];
fn tower_color(index: usize) -> [f32; 4] {
[
[1.0, 0.5, 0.0, 1.0],
[0.2, 0.6, 1.0, 1.0],
[0.8, 0.2, 0.2, 1.0],
[0.3, 0.3, 0.3, 1.0],
[0.6, 0.2, 0.8, 1.0],
][index]
}
fn build_scene(world: &mut World) {
set_title(world, "Tower Defense (scripted)");
set_background(world, Background::Nebula);
set_bloom(world, true);
show_grid(world, false);
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 path = waypoints();
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));
}
}
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,
);
for x in -6..=6 {
for z in -6..=6 {
if ((x - start.0).abs() <= 1 && (z - start.1).abs() <= 1)
|| ((x - end.0).abs() <= 1 && (z - end.1).abs() <= 1)
{
continue;
}
let on_path = cells.contains(&(x, z));
let color = if on_path {
[0.5, 0.3, 0.1, 1.0]
} else {
[0.1, 0.3, 0.1, 1.0]
};
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,
},
);
}
}
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);
}
fn build_hud(world: &mut World) {
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,
[0.92, 0.94, 1.0, 1.0],
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(220.0, 12.0),
[0.08, 0.1, 0.14, 1.0],
);
let hp_fill = panel_box(
world,
status,
vec2(14.0, 78.0),
vec2(220.0, 12.0),
GREEN_ACCENT,
);
let speed_panel = spawn_panel_at(
world,
ScreenAnchor::TopRight,
vec2(-20.0, 144.0),
vec2(260.0, 50.0),
PANEL_BG_DEEP,
);
panel_text(
world,
speed_panel,
"SPEED",
[12.0, 6.0, 80.0, 14.0],
10.0,
TEXT_FAINT,
TextAlignment::Left,
);
let speed = panel_text(
world,
speed_panel,
"1.0x",
[12.0, 22.0, 120.0, 22.0],
18.0,
BLUE_ACCENT,
TextAlignment::Left,
);
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,
[0.92, 0.94, 1.0, 1.0],
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 names = ["Basic", "Frost", "Cannon", "Sniper", "Poison"];
let costs = [60, 120, 200, 180, 150];
let descriptions = [
"Balanced fire",
"Slows on hit",
"Splash damage",
"Charged shot",
"Damage over time",
];
for index in 0..5 {
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));
panel_box(
world,
chip,
vec2(8.0, 6.0),
vec2(18.0, 18.0),
tower_color(index),
);
panel_text(
world,
chip,
&format!("{}", index + 1),
[8.0, 6.0, 18.0, 18.0],
12.0,
[0.92, 0.94, 1.0, 1.0],
TextAlignment::Center,
);
panel_text(
world,
chip,
names[index],
[32.0, 6.0, 94.0, 20.0],
16.0,
[0.92, 0.94, 1.0, 1.0],
TextAlignment::Left,
);
panel_text(
world,
chip,
&format!("${}", costs[index]),
[32.0, 26.0, 94.0, 14.0],
12.0,
GOLD,
TextAlignment::Left,
);
panel_text(
world,
chip,
descriptions[index],
[8.0, 44.0, 118.0, 14.0],
10.0,
TEXT_DIM,
TextAlignment::Left,
);
set_panel_selected(
world,
chip,
index == 0,
[
tower_color(index)[0],
tower_color(index)[1],
tower_color(index)[2],
0.35,
],
);
name_entity(world, &format!("hud_chip_{index}"), chip);
}
spawn_text(
world,
"left click place / right click sell / scroll or 1-5 select / [ ] speed / C reset view",
ScreenAnchor::BottomCenter,
);
name_entity(world, "hud_money", money);
name_entity(world, "hud_wave", wave);
name_entity(world, "hud_lives", lives);
name_entity(world, "hud_hp", hp);
name_entity(world, "hud_hp_fill", hp_fill);
name_entity(world, "hud_speed", speed);
name_entity(world, "hud_announce", announce_root);
name_entity(world, "hud_announce_label", announce_label);
name_entity(world, "hud_status", status_root);
name_entity(world, "hud_status_label", status_label);
}
fn main() {
run(
|world| {
build_scene(world);
build_hud(world);
world.resources.global_scripts.entries.push(GlobalScript {
name: "tower_defense".to_string(),
source: GAME.to_string(),
enabled: true,
});
let mut runtime = ScriptRuntime::default();
runtime.enabled = true;
Scripted { runtime }
},
|world, scripted| {
run_scripts(world, &mut scripted.runtime);
},
)
.unwrap();
}
const GAME: &str = r#"
fn cost(k) { [60, 120, 200, 180, 150][k] }
fn dmg(k) { [15.0, 8.0, 50.0, 80.0, 5.0][k] }
fn rng_range(k) { [3.0, 2.5, 4.0, 6.0, 2.8][k] }
fn fire_rate(k) { [0.5, 1.0, 2.0, 3.0, 0.8][k] }
fn proj_speed(k) { [12.0, 8.0, 10.0, 20.0, 10.0][k] }
fn tcolor(k) { [[1.0,0.5,0.0,1.0],[0.2,0.6,1.0,1.0],[0.8,0.2,0.2,1.0],[0.3,0.3,0.3,1.0],[0.6,0.2,0.8,1.0]][k] }
fn ecolor(k) { [[0.8,0.2,0.2,1.0],[1.0,0.5,0.0,1.0],[0.4,0.4,0.4,1.0],[0.5,0.8,1.0,1.0],[0.2,0.6,0.9,1.0],[0.2,0.9,0.4,1.0],[0.6,0.0,0.6,1.0]][k] }
fn ebase_hp(k) { [50.0, 30.0, 150.0, 40.0, 60.0, 80.0, 500.0][k] }
fn espeed(k) { [2.0, 4.0, 1.0, 2.5, 1.5, 1.8, 0.8][k] }
fn evalue(k) { [10, 15, 30, 20, 25, 35, 100][k] }
fn eshield(k) { [0.0, 0.0, 0.0, 0.0, 30.0, 0.0, 100.0][k] }
fn eyoff(k) { [-0.15, -0.05, -0.05, 1.85, -0.15, -0.2, 0.15][k] }
fn enemy_parts(kind, ex, ey, ez) {
let c = ecolor(kind);
let dim = [c[0] * 0.85, c[1] * 0.85, c[2] * 0.85, 1.0];
let ps = [1.0, 1.0, 1.0];
let parts = [];
if kind == 0 {
ps = [0.5, 0.6, 0.4];
parts.push([0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([1, 0.0, 0.5, 0.0, 0.3, 0.3, 0.3, dim]);
parts.push([0, -0.4, -0.1, 0.0, 0.16, 0.4, 0.16, dim]);
parts.push([0, 0.4, -0.1, 0.0, 0.16, 0.4, 0.16, dim]);
} else if kind == 1 {
ps = [0.44, 0.8, 0.44];
parts.push([2, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([1, 0.0, 0.6, 0.0, 0.26, 0.26, 0.26, c]);
} else if kind == 2 {
ps = [0.7, 0.8, 0.6];
parts.push([0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([0, 0.0, 0.6, 0.0, 0.5, 0.5, 0.5, dim]);
parts.push([0, -0.5, 0.3, 0.0, 0.3, 0.3, 0.3, c]);
parts.push([0, 0.5, 0.3, 0.0, 0.3, 0.3, 0.3, c]);
} else if kind == 3 {
ps = [0.44, 0.56, 0.44];
parts.push([1, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([2, -0.6, 0.0, 0.0, 0.36, 0.2, 0.36, [c[0] * 0.5, c[1] * 0.5, c[2] * 0.5, 0.7]]);
parts.push([2, 0.6, 0.0, 0.0, 0.36, 0.2, 0.36, [c[0] * 0.5, c[1] * 0.5, c[2] * 0.5, 0.7]]);
} else if kind == 4 {
ps = [0.5, 0.6, 0.4];
parts.push([0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([1, 0.0, 0.5, 0.0, 0.3, 0.3, 0.3, dim]);
parts.push([3, 0.45, 0.0, 0.0, 0.6, 0.12, 0.6, [0.7, 0.7, 1.0, 0.6]]);
} else if kind == 5 {
ps = [0.5, 0.5, 0.5];
parts.push([4, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([1, 0.0, 0.0, 0.0, 0.32, 0.32, 0.32, [0.3, 1.0, 0.3, 1.0]]);
} else {
ps = [1.0, 1.2, 0.8];
parts.push([0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, c]);
parts.push([0, 0.0, 0.8, 0.0, 0.7, 0.7, 0.7, dim]);
parts.push([2, 0.0, 1.3, 0.0, 0.5, 0.5, 0.5, [1.0, 0.85, 0.0, 1.0]]);
parts.push([0, -0.7, 0.4, 0.0, 0.35, 0.45, 0.35, c]);
parts.push([0, 0.7, 0.4, 0.0, 0.35, 0.45, 0.35, c]);
}
let out = [];
for prt in parts {
let wx = ex + prt[1] * ps[0];
let wy = ey + prt[2] * ps[1];
let wz = ez + prt[3] * ps[2];
let wsx = prt[4] * ps[0];
let wsy = prt[5] * ps[1];
let wsz = prt[6] * ps[2];
let col = prt[7];
if prt[0] == 0 {
out.push(#{ DrawCube: #{ position: [wx, wy, wz], scale: [wsx, wsy, wsz], color: col } });
} else if prt[0] == 1 {
out.push(#{ DrawSphere: #{ position: [wx, wy, wz], radius: (wsx + wsy + wsz) / 3.0, color: col } });
} else if prt[0] == 2 {
out.push(#{ DrawCone: #{ position: [wx, wy, wz], scale: [wsx, wsy, wsz], color: col } });
} else if prt[0] == 3 {
out.push(#{ DrawCylinder: #{ position: [wx, wy, wz], scale: [wsx, wsy, wsz], color: col } });
} else {
out.push(#{ DrawTorus: #{ position: [wx, wy, wz], scale: [wsx, wsy, wsz], color: col } });
}
}
out
}
fn waypoints() { [[-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]] }
fn seg_len(a, b) {
let dx = b[0] - a[0];
let dz = b[1] - a[1];
(dx * dx + dz * dz).sqrt()
}
fn enemy_xy(path, idx, prog) {
let a = path[idx];
let b = path[idx + 1];
[a[0] + (b[0] - a[0]) * prog, a[1] + (b[1] - a[1]) * prog]
}
fn on_tick() {
if !("ready" in state) {
state.ready = true;
state.money = 200;
state.lives = 20;
state.wave = 0;
state.hp = 20;
state.max_hp = 20;
state.phase = "waiting";
state.wave_delay = 3.0;
state.announce = 0.0;
state.spawn_timer = 0.0;
state.speed = 1.0;
state.selected = 0;
state.next_id = 1;
state.rng = 987654321;
state.queue = [];
state.enemies = [];
state.towers = [];
state.projectiles = [];
state.popups = [];
state.path = waypoints();
state.cells = #{};
let path = state.path;
for i in 0..(path.len() - 1) {
for s in 0..21 {
let t = (s.to_float()) / 20.0;
let x = path[i][0] + (path[i + 1][0] - path[i][0]) * t;
let z = path[i][1] + (path[i + 1][1] - path[i][1]) * t;
let key = (x.round().to_int()).to_string() + "," + (z.round().to_int()).to_string();
state.cells[key] = true;
}
}
}
let step = dt * state.speed;
if state.announce > 0.0 { state.announce -= dt; }
// --- input ---
if mouse.scroll > 0.0 { state.selected = (state.selected + 4) % 5; }
if mouse.scroll < 0.0 { state.selected = (state.selected + 1) % 5; }
if "Digit1" in pressed { state.selected = 0; }
if "Digit2" in pressed { state.selected = 1; }
if "Digit3" in pressed { state.selected = 2; }
if "Digit4" in pressed { state.selected = 3; }
if "Digit5" in pressed { state.selected = 4; }
if "BracketLeft" in pressed { state.speed = (state.speed - 0.5).max(0.5); }
if "BracketRight" in pressed { state.speed = (state.speed + 0.5).min(3.0); }
if "Backslash" in pressed { state.speed = 1.0; }
if ("KeyC" in pressed) || ("Home" in pressed) {
commands.push(#{ SetOrbitView: #{ focus: [0.0,0.0,0.0], radius: 16.1, yaw: 0.0, pitch: 0.52 } });
}
let on_ground = ground.len() == 3;
let cell_x = if on_ground { ground[0].round() } else { 0.0 };
let cell_z = if on_ground { ground[2].round() } else { 0.0 };
let cell_key = (cell_x.to_int()).to_string() + "," + (cell_z.to_int()).to_string();
let path_cell = cell_key in state.cells;
let in_grid = cell_x >= -6.0 && cell_x <= 6.0 && cell_z >= -6.0 && cell_z <= 6.0;
let tower_here = -1;
for ti in 0..state.towers.len() {
if state.towers[ti].x == cell_x && state.towers[ti].z == cell_z { tower_here = ti; }
}
if state.phase != "over" && on_ground && !pointer_over_ui {
if mouse.left_pressed && in_grid && !path_cell && tower_here < 0 && state.money >= cost(state.selected) {
state.money -= cost(state.selected);
state.towers.push(#{ kind: state.selected, x: cell_x, z: cell_z, cd: 0.0, target: -1, track: 0.0, anim: 0.0 });
state.popups.push(#{ x: cell_x, y: 0.5, z: cell_z, age: 0.0, text: "-" + cost(state.selected).to_string() });
}
if mouse.right_pressed && tower_here >= 0 {
let refund = (cost(state.towers[tower_here].kind).to_float() * 0.7).to_int();
state.money += refund;
state.popups.push(#{ x: cell_x, y: 0.5, z: cell_z, age: 0.0, text: "+" + refund.to_string() });
state.towers.remove(tower_here);
}
}
// --- waves ---
if state.phase == "waiting" {
state.wave_delay -= step;
if state.wave_delay <= 0.0 {
state.wave += 1;
state.phase = "playing";
state.spawn_timer = 0.0;
let w = state.wave;
let boss = w % 5 == 0;
state.announce = if boss { 3.0 } else { 2.0 };
let count = 5 + w * 2;
let interval = if w <= 3 { 1.0 } else if w <= 6 { 0.8 } else { 0.6 };
let table = wave_table(w, boss);
let queue = [];
let tspawn = 0.0;
for n in 0..count {
state.rng = (state.rng * 1103515245 + 12345) % 2147483647;
let roll = state.rng.to_float() / 2147483647.0;
let cumulative = 0.0;
let chosen = 0;
for entry in table {
cumulative += entry[1];
if roll < cumulative { chosen = entry[0]; break; }
}
queue.push([chosen, tspawn]);
tspawn += interval;
}
state.queue = queue;
}
} else if state.phase == "playing" {
state.spawn_timer += step;
let still = [];
for item in state.queue {
if item[1] <= state.spawn_timer {
let k = item[0];
let mult = 1.0 + (state.wave.to_float() - 1.0) * 0.5;
state.enemies.push(#{
id: state.next_id, kind: k, hp: ebase_hp(k) * mult, shield: eshield(k),
speed: espeed(k), idx: 0, prog: 0.0, value: evalue(k) + state.wave * 2,
slow: 0.0, poison: 0.0, x: state.path[0][0], z: state.path[0][1]
});
state.next_id += 1;
} else {
still.push(item);
}
}
state.queue = still;
if state.queue.len() == 0 && state.enemies.len() == 0 {
state.phase = "waiting";
state.wave_delay = 3.0;
}
}
// --- enemy movement ---
let path = state.path;
let survivors = [];
for e in state.enemies {
if e.slow > 0.0 { e.slow -= step; }
if e.poison > 0.0 { e.poison -= step; e.hp -= 2.0 * step; }
if e.hp <= 0.0 {
state.money += e.value;
let xy = enemy_xy(path, e.idx, e.prog);
state.popups.push(#{ x: xy[0], y: eyoff(e.kind) + 0.5, z: xy[1], age: 0.0, text: "+" + e.value.to_string() });
commands.push(#{ EmitBurst: #{ position: [xy[0], eyoff(e.kind), xy[1]], color: [0.5,0.5,0.5,0.8], count: 20 } });
continue;
}
let spd = if e.slow > 0.0 { e.speed * 0.5 } else { e.speed };
let len = seg_len(path[e.idx], path[e.idx + 1]);
e.prog += (spd * step) / len;
while e.prog >= 1.0 && e.idx < path.len() - 2 {
e.prog -= 1.0;
e.idx += 1;
}
if e.idx >= path.len() - 2 && e.prog >= 1.0 {
state.hp -= 1;
if state.hp <= 0 {
state.hp = state.max_hp;
state.lives -= 1;
if state.lives <= 0 { state.phase = "over"; }
}
continue;
}
let xy = enemy_xy(path, e.idx, e.prog);
e.x = xy[0];
e.z = xy[1];
survivors.push(e);
}
state.enemies = survivors;
// Per-frame enemy lookups so towers and projectiles never rescan (and copy)
// the whole enemy array: a flat [id, x, z] list for nearest search, id -> [x,
// z, yoff] for target reads, id -> index for damage write-back.
let elist = [];
let emap = #{};
let eindex = #{};
for ei in 0..state.enemies.len() {
let e = state.enemies[ei];
let key = e.id.to_string();
elist.push([e.id, e.x, e.z]);
emap[key] = [e.x, e.z, eyoff(e.kind)];
eindex[key] = ei;
}
// --- towers --- (index + write-back: rhai for-in yields copies)
for ti in 0..state.towers.len() {
let t = state.towers[ti];
if t.cd > 0.0 { t.cd -= step; }
if t.anim > 0.0 { t.anim = (t.anim - step * 3.0).max(0.0); }
if t.target >= 0 && !(t.target.to_string() in emap) { t.target = -1; t.track = 0.0; }
if t.target < 0 {
let best = -1;
let bestd = rng_range(t.kind);
for ent in elist {
let dx = ent[1] - t.x;
let dz = ent[2] - t.z;
let d = (dx*dx + dz*dz).sqrt();
if d <= bestd { bestd = d; best = ent[0]; }
}
t.target = best;
} else if t.kind == 3 {
t.track += step;
}
if t.cd <= 0.0 && t.target >= 0 {
let ready = if t.kind == 3 { t.track >= 2.0 } else { true };
if ready && (t.target.to_string() in emap) {
t.cd = fire_rate(t.kind);
t.anim = 1.0;
state.projectiles.push(#{
kind: t.kind, dmg: dmg(t.kind), target: t.target, speed: proj_speed(t.kind),
x: t.x, y: 0.5, z: t.z, arc: if t.kind == 2 { 2.0 } else { 0.0 }, flight: 0.0,
sx: t.x, sz: t.z
});
if t.kind == 2 {
commands.push(#{ EmitBurst: #{ position: [t.x, 0.8, t.z], color: [1.0,0.9,0.3,1.0], count: 12 } });
}
}
}
state.towers[ti] = t;
}
// --- projectiles ---
let live_proj = [];
for p in state.projectiles {
let tk = p.target.to_string();
if !(tk in emap) { continue; }
let tp = emap[tk];
let tx = tp[0]; let tz = tp[1]; let ty = tp[2];
let hit = false;
if p.arc > 0.0 {
let total = ((((tx - p.sx)*(tx - p.sx)) + ((tz - p.sz)*(tz - p.sz))).sqrt()).max(0.01);
p.flight = (p.flight + p.speed * step / total).min(1.0);
p.x = p.sx + (tx - p.sx) * p.flight;
p.z = p.sz + (tz - p.sz) * p.flight;
p.y = 0.5 + ty + 4.0 * 2.0 * p.flight * (1.0 - p.flight);
if p.flight >= 1.0 { hit = true; }
} else {
let dx = tx - p.x;
let dz = tz - p.z;
let dy = (ty + 0.3) - p.y;
let d = (dx*dx + dy*dy + dz*dz).sqrt();
if d < 0.3 {
hit = true;
} else {
p.x += dx / d * p.speed * step;
p.y += dy / d * p.speed * step;
p.z += dz / d * p.speed * step;
}
}
if hit {
if p.kind == 2 || p.kind == 3 {
commands.push(#{ EmitBurst: #{ position: [tx, ty, tz], color: [1.0,0.5,0.0,1.0], count: 8 } });
}
if p.kind == 4 {
commands.push(#{ EmitBurst: #{ position: [tx, ty, tz], color: [0.5,0.0,0.8,0.6], count: 5 } });
}
let ei = eindex[tk];
let e = state.enemies[ei];
let remaining = p.dmg;
if e.shield > 0.0 {
let absorb = remaining.min(e.shield);
e.shield -= absorb;
remaining -= absorb;
}
e.hp -= remaining;
if p.kind == 1 { e.slow = 2.0; }
if p.kind == 4 { e.poison = 3.0; }
state.enemies[ei] = e;
continue;
}
live_proj.push(p);
}
state.projectiles = live_proj;
// --- render dynamic objects ---
for e in state.enemies {
for cmd in enemy_parts(e.kind, e.x, eyoff(e.kind), e.z) { commands.push(cmd); }
}
for t in state.towers {
let pulse = 1.0 + t.anim * 0.2;
let flash = t.anim;
let base = tcolor(t.kind);
let lit = [(base[0] * 0.4 + flash).min(1.0), (base[1] * 0.4 + flash).min(1.0), (base[2] * 0.4 + flash).min(1.0), 1.0];
commands.push(#{ DrawCylinder: #{ position: [t.x, 0.0, t.z], scale: [0.4 * pulse, 0.8, 0.4 * pulse], color: lit } });
if t.kind == 3 && t.target >= 0 && (t.target.to_string() in emap) {
let tp = emap[t.target.to_string()];
commands.push(#{ DrawLine: #{ start: [t.x, 0.5, t.z], end: [tp[0], tp[2] + 0.3, tp[1]], color: [1.0,0.0,0.0,0.8] } });
}
}
for p in state.projectiles {
commands.push(#{ DrawSphere: #{ position: [p.x, p.y, p.z], radius: 0.15, color: tcolor(p.kind) } });
}
// placement ghost + range ring
if state.phase != "over" && on_ground && !pointer_over_ui && in_grid && !path_cell && tower_here < 0 {
let c = tcolor(state.selected);
commands.push(#{ DrawCylinder: #{ position: [cell_x, 0.0, cell_z], scale: [0.4, 0.8, 0.4], color: [c[0], c[1], c[2], 0.4] } });
let r = rng_range(state.selected);
let segs = 28;
for i in 0..segs {
let a0 = (i.to_float()) / (segs.to_float()) * 6.2831853;
let a1 = ((i + 1).to_float()) / (segs.to_float()) * 6.2831853;
commands.push(#{ DrawLine: #{
start: [cell_x + a0.cos() * r, 0.1, cell_z + a0.sin() * r],
end: [cell_x + a1.cos() * r, 0.1, cell_z + a1.sin() * r],
color: [c[0], c[1], c[2], 0.5]
} });
}
}
// money popups
let live_pop = [];
for pop in state.popups {
pop.age += dt;
if pop.age >= 1.0 { continue; }
pop.y += dt * 1.5;
commands.push(#{ DrawText3d: #{ text: pop.text, position: [pop.x, pop.y, pop.z] } });
live_pop.push(pop);
}
state.popups = live_pop;
// --- HUD ---
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_money"]), text: "$" + state.money.to_string() } });
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_wave"]), text: state.wave.to_string() } });
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_lives"]), text: "x" + state.lives.to_string() } });
let shown_hp = if state.hp > 0 { state.hp } else { 0 };
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_hp"]), text: "HP " + shown_hp.to_string() + "/" + state.max_hp.to_string() } });
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_speed"]), text: state.speed.to_string() + "x" } });
let ratio = if state.hp > 0 { state.hp.to_float() / state.max_hp.to_float() } else { 0.0 };
commands.push(#{ SetPanelRect: #{ node: entity_ref(named["hud_hp_fill"]), offset: [14.0, 78.0], size: [220.0 * ratio, 12.0] } });
let hpcol = 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] };
commands.push(#{ SetPanelColor: #{ node: entity_ref(named["hud_hp_fill"]), color: hpcol } });
for i in 0..5 {
let c = tcolor(i);
commands.push(#{ SetPanelSelected: #{ button: entity_ref(named["hud_chip_" + i.to_string()]), selected: i == state.selected, accent: [c[0], c[1], c[2], 0.35] } });
}
let show_announce = state.announce > 0.0;
commands.push(#{ SetPanelVisible: #{ node: entity_ref(named["hud_announce"]), visible: show_announce } });
if show_announce {
let label = if state.wave % 5 == 0 { "BOSS WAVE " + state.wave.to_string() } else { "WAVE " + state.wave.to_string() };
commands.push(#{ SetPanelText: #{ label: entity_ref(named["hud_announce_label"]), text: label } });
}
let over = state.phase == "over";
commands.push(#{ SetPanelVisible: #{ node: entity_ref(named["hud_status"]), visible: over } });
}
fn wave_table(w, boss) {
if boss {
if w == 5 { return [[0,0.3],[1,0.3],[2,0.2],[6,0.2]]; }
if w == 10 { return [[0,0.25],[1,0.25],[2,0.15],[3,0.15],[6,0.2]]; }
if w == 15 { return [[0,0.2],[1,0.2],[2,0.15],[3,0.15],[4,0.1],[6,0.2]]; }
return [[0,0.15],[1,0.15],[2,0.15],[3,0.15],[4,0.1],[5,0.1],[6,0.2]];
}
if w <= 2 { return [[0,1.0]]; }
if w <= 4 { return [[0,0.7],[1,0.3]]; }
if w <= 9 { return [[0,0.4],[1,0.3],[2,0.2],[3,0.1]]; }
if w <= 14 { return [[0,0.3],[1,0.25],[2,0.2],[3,0.15],[4,0.1]]; }
[[0,0.25],[1,0.2],[2,0.2],[3,0.15],[4,0.1],[5,0.1]]
}
"#;