# ==============================================================================
# TOWER DEFENSE — Полноценный профессиональный скрипт для Godot 4
# Вставь этот скрипт на корневой узел Node3D твоей сцены.
#
# ЧТО НУЖНО В СЦЕНЕ:
# - Node3D (корень) — этот скрипт
# - Camera3D (имя: "Camera3D") — настрой вид сверху, угол ~60°
# - DirectionalLight3D — освещение
# - MeshInstance3D "Ground" — плоскость земли (PlaneMesh 40x40)
# - Area3D + CollisionShape3D "PlacementArea" — вся карта для кликов
# - Node3D "Towers" — контейнер башен
# - Node3D "Enemies" — контейнер врагов
# - Node3D "Projectiles" — контейнер снарядов
# - Node3D "Effects" — контейнер эффектов
# - CanvasLayer "UI" с Control — для HUD (создаётся автоматически кодом)
#
# ПУТЬ ВРАГОВ: задаётся массивом PATH_WAYPOINTS ниже.
# ЗАМЕНА МОДЕЛЕЙ: ищи функции _create_*_mesh() и замени MeshInstance3D
# на load("res://твоя_модель.glb")
# ==============================================================================
extends Node3D
# ─── НАСТРОЙКИ ПУТИ (мировые координаты XZ) ────────────────────────────────
const PATH_WAYPOINTS: Array[Vector3] = [
Vector3(-18, 0, -18),
Vector3(-18, 0, -6),
Vector3( -6, 0, -6),
Vector3( -6, 0, 6),
Vector3( 6, 0, 6),
Vector3( 6, 0, -6),
Vector3( 18, 0, -6),
Vector3( 18, 0, 18),
]
# ─── НАСТРОЙКИ БАШЕН ────────────────────────────────────────────────────────
const TOWER_DEFS: Dictionary = {
"basic": {
"name": "Стрелок",
"cost": 50,
"damage": 20,
"range": 6.0,
"fire_rate": 1.2,
"speed": 18.0,
"color": Color(0.2, 0.6, 1.0),
"proj_color":Color(0.4, 0.8, 1.0),
"desc": "Базовая башня. Дешёво и сердито.",
"sell": 25,
},
"sniper": {
"name": "Снайпер",
"cost": 120,
"damage": 80,
"range": 14.0,
"fire_rate": 2.8,
"speed": 30.0,
"color": Color(0.9, 0.7, 0.1),
"proj_color":Color(1.0, 0.9, 0.3),
"desc": "Дальнобойная. Медленная, но мощная.",
"sell": 60,
},
"rapid": {
"name": "Пулемёт",
"cost": 90,
"damage": 8,
"range": 5.0,
"fire_rate": 0.18,
"speed": 22.0,
"color": Color(1.0, 0.3, 0.2),
"proj_color":Color(1.0, 0.5, 0.2),
"desc": "Очень быстрая стрельба, малый урон.",
"sell": 45,
},
"frost": {
"name": "Мороз",
"cost": 110,
"damage": 15,
"range": 6.5,
"fire_rate": 1.5,
"speed": 14.0,
"color": Color(0.3, 0.9, 0.9),
"proj_color":Color(0.6, 1.0, 1.0),
"desc": "Замедляет врагов на 50%.",
"sell": 55,
"slow": 0.5,
"slow_dur": 2.0,
},
"mortar": {
"name": "Миномёт",
"cost": 200,
"damage": 120,
"range": 10.0,
"fire_rate": 4.0,
"speed": 12.0,
"color": Color(0.5, 0.3, 0.8),
"proj_color":Color(0.8, 0.4, 1.0),
"desc": "Взрывной урон по области (радиус 3).",
"sell": 100,
"splash": 3.0,
},
}
# ─── НАСТРОЙКИ ВРАГОВ ───────────────────────────────────────────────────────
const ENEMY_DEFS: Dictionary = {
"grunt": {
"name": "Пехотинец",
"hp": 80,
"speed": 3.5,
"reward": 10,
"color": Color(0.2, 0.8, 0.2),
"size": 0.5,
"armor": 0.0,
},
"heavy": {
"name": "Танк",
"hp": 400,
"speed": 2.0,
"reward": 30,
"color": Color(0.5, 0.5, 0.5),
"size": 0.8,
"armor": 0.3,
},
"fast": {
"name": "Разведчик",
"hp": 50,
"speed": 7.0,
"reward": 15,
"color": Color(1.0, 0.8, 0.1),
"size": 0.35,
"armor": 0.0,
},
"boss": {
"name": "Босс",
"hp": 2000,
"speed": 1.8,
"reward": 150,
"color": Color(0.9, 0.1, 0.1),
"size": 1.2,
"armor": 0.4,
},
"swarm": {
"name": "Рой",
"hp": 25,
"speed": 5.5,
"reward": 5,
"color": Color(0.8, 0.4, 0.9),
"size": 0.3,
"armor": 0.0,
},
}
# ─── ВОЛНЫ ──────────────────────────────────────────────────────────────────
const WAVES: Array = [
[{"type":"grunt", "count":8, "interval":1.0}],
[{"type":"grunt", "count":10, "interval":0.8}, {"type":"fast", "count":4, "interval":1.5}],
[{"type":"heavy", "count":3, "interval":2.5}, {"type":"grunt","count":8, "interval":0.8}],
[{"type":"fast", "count":12, "interval":0.4}],
[{"type":"grunt", "count":6, "interval":0.7}, {"type":"heavy","count":4,"interval":2.0}, {"type":"fast","count":6,"interval":0.6}],
[{"type":"swarm", "count":20, "interval":0.25}],
[{"type":"heavy", "count":6, "interval":1.8}, {"type":"swarm","count":15,"interval":0.3}],
[{"type":"boss", "count":1, "interval":5.0}, {"type":"grunt","count":10,"interval":0.6}],
[{"type":"fast", "count":15, "interval":0.35},{"type":"heavy","count":5,"interval":1.5}],
[{"type":"boss", "count":2, "interval":8.0}, {"type":"swarm","count":25,"interval":0.2}, {"type":"heavy","count":8,"interval":1.2}],
]
# ─── ИГРОВЫЕ ПЕРЕМЕННЫЕ ─────────────────────────────────────────────────────
var gold: int = 150
var lives: int = 20
var score: int = 0
var wave_index: int = 0
var game_state: String = "prepare"
var selected_tower_type: String = "basic"
var hovered_cell: Vector3i = Vector3i(-999, 0, -999)
var placed_towers: Dictionary = {}
var preview_node: Node3D = null
var can_place: bool = false
# ─── ВНУТРЕННИЕ УЗЛЫ ────────────────────────────────────────────────────────
var towers_root: Node3D
var enemies_root: Node3D
var projectiles_root:Node3D
var effects_root: Node3D
var ui_layer: CanvasLayer
# ─── UI ЭЛЕМЕНТЫ ────────────────────────────────────────────────────────────
var lbl_gold: Label
var lbl_lives: Label
var lbl_wave: Label
var lbl_score: Label
var btn_start: Button
var panel_info: PanelContainer
var lbl_info: Label
var bar_container: HBoxContainer
var tower_buttons: Dictionary = {}
var lbl_msg: Label
var speed_btn: Button
var game_speed: float = 1.0
# ─── СПАВН ВОЛН ─────────────────────────────────────────────────────────────
var spawn_queue: Array = []
var wave_active: bool = false
var wave_timer: float = 0.0
var between_timer: float = 0.0
const BETWEEN_WAVE_TIME: float = 8.0
# ─── PATH КЭШИ ──────────────────────────────────────────────────────────────
var path_cells: Array[Vector3i] = []
# ═══════════════════════════════════════════════════════════════════════════
func _ready() -> void:
Engine.max_fps = 120
_get_scene_nodes()
_build_path_visual()
_compute_path_cells()
_build_ui()
_create_preview()
_update_ui()
_show_message("TOWER DEFENSE\nВолна 1 из %d\nНажми НАЧАТЬ ВОЛНУ" % WAVES.size())
func _get_scene_nodes() -> void:
towers_root = _get_or_create("Towers", Node3D)
enemies_root = _get_or_create("Enemies", Node3D)
projectiles_root = _get_or_create("Projectiles", Node3D)
effects_root = _get_or_create("Effects", Node3D)
func _get_or_create(node_name: String, type) -> Node:
var n = get_node_or_null(node_name)
if n == null:
n = type.new()
n.name = node_name
add_child(n)
return n
# ═══════════════════════════════════════════════════════════════════════════
func _process(delta: float) -> void:
var d = delta * game_speed
if game_state == "wave":
_process_spawning(d)
_process_towers(d)
_process_projectiles(d)
_process_enemies(d)
_check_wave_done()
elif game_state == "prepare":
_process_towers(d)
_process_projectiles(d)
_process_enemies(d)
_process_effects(d)
_update_preview()
# ═══════════════════════════════════════════════════════════════════════════
func _start_wave() -> void:
if wave_index >= WAVES.size(): return
game_state = "wave"
wave_active = true
spawn_queue.clear()
btn_start.visible = false
lbl_msg.visible = false
var wave_def = WAVES[wave_index]
var offset = 0.0
for group in wave_def:
for i in range(group["count"]):
spawn_queue.append({"type": group["type"], "delay": offset + i * group["interval"]})
offset += group["count"] * group["interval"] + 1.0
spawn_queue.sort_custom(func(a, b): return a["delay"] < b["delay"])
wave_timer = 0.0
func _process_spawning(delta: float) -> void:
if spawn_queue.is_empty(): return
wave_timer += delta
while not spawn_queue.is_empty() and spawn_queue[0]["delay"] <= wave_timer:
_spawn_enemy(spawn_queue.pop_front()["type"])
func _spawn_enemy(type: String) -> void:
var def = ENEMY_DEFS[type]
var node = Node3D.new()
node.name = "Enemy_" + type
node.global_position = PATH_WAYPOINTS[0]
enemies_root.add_child(node)
var mesh_inst = _create_enemy_mesh(def)
node.add_child(mesh_inst)
var hp_bar = _create_hp_bar()
node.add_child(hp_bar)
node.set_meta("type", type)
node.set_meta("hp", int(def["hp"]))
node.set_meta("max_hp", int(def["hp"]))
node.set_meta("speed", float(def["speed"]))
node.set_meta("base_speed", float(def["speed"]))
node.set_meta("reward", int(def["reward"]))
node.set_meta("armor", float(def["armor"]))
node.set_meta("wp_index", 1)
node.set_meta("slow_timer", 0.0)
node.set_meta("hp_bar", hp_bar)
node.set_meta("alive", true)
func _process_enemies(delta: float) -> void:
for enemy in enemies_root.get_children():
if not enemy.get_meta("alive", false): continue
var wp_idx: int = enemy.get_meta("wp_index")
if wp_idx >= PATH_WAYPOINTS.size():
lives -= 1
_update_ui()
_flash_damage_ui()
enemy.set_meta("alive", false)
enemy.queue_free()
if lives <= 0: _game_over()
continue
var slow_t: float = enemy.get_meta("slow_timer")
if slow_t > 0.0:
enemy.set_meta("slow_timer", slow_t - delta)
enemy.set_meta("speed", enemy.get_meta("base_speed") * 0.5)
else:
enemy.set_meta("speed", enemy.get_meta("base_speed"))
var target_wp = PATH_WAYPOINTS[wp_idx]
var dir = (target_wp - enemy.global_position)
dir.y = 0.0
var dist = dir.length()
var spd: float = enemy.get_meta("speed")
if dist < 0.1:
enemy.set_meta("wp_index", wp_idx + 1)
else:
var move = dir.normalized() * spd * delta
enemy.global_position += move
if dir.length() > 0.01:
var look_target = enemy.global_position + dir.normalized()
look_target.y = enemy.global_position.y
enemy.look_at(look_target, Vector3.UP)
_update_enemy_hp_bar(enemy)
func _check_wave_done() -> void:
if not spawn_queue.is_empty(): return
if enemies_root.get_child_count() > 0:
var alive = false
for e in enemies_root.get_children():
if e.get_meta("alive", false): alive = true; break
if alive: return
wave_index += 1
if wave_index >= WAVES.size():
_victory()
return
game_state = "prepare"
wave_active = false
var bonus = 50 + wave_index * 10
gold += bonus
score += bonus
_update_ui()
btn_start.visible = true
btn_start.text = "▶ ВОЛНА %d / %d" % [wave_index + 1, WAVES.size()]
_show_message("Волна %d пройдена!\n+%d золота\nСледующая волна: %d / %d" % [
wave_index, bonus, wave_index + 1, WAVES.size()])
# ═══════════════════════════════════════════════════════════════════════════
func _place_tower(cell: Vector3i) -> void:
if placed_towers.has(cell): return
if _is_path_cell(cell): return
var def = TOWER_DEFS[selected_tower_type]
if gold < def["cost"]:
_show_message("Недостаточно золота!", 1.5)
return
gold -= def["cost"]
var node = Node3D.new()
node.name = "Tower_" + selected_tower_type
node.global_position = Vector3(cell.x + 0.5, 0.0, cell.z + 0.5)
towers_root.add_child(node)
var mesh_inst = _create_tower_mesh(def)
node.add_child(mesh_inst)
var range_ring = _create_range_ring(def["range"], def["color"])
range_ring.visible = false
node.add_child(range_ring)
node.set_meta("tower_type", selected_tower_type)
node.set_meta("damage", int(def["damage"]))
node.set_meta("range", float(def["range"]))
node.set_meta("fire_rate", float(def["fire_rate"]))
node.set_meta("fire_timer", 0.0)
node.set_meta("speed", float(def["speed"]))
node.set_meta("color", def["color"])
node.set_meta("proj_color", def["proj_color"])
node.set_meta("slow", def.get("slow", 0.0))
node.set_meta("slow_dur", def.get("slow_dur", 0.0))
node.set_meta("splash", def.get("splash", 0.0))
node.set_meta("sell", int(def["sell"]))
node.set_meta("range_ring", range_ring)
node.set_meta("kills", 0)
node.set_meta("total_dmg", 0)
placed_towers[cell] = node
score += 2
_update_ui()
_spawn_place_effect(node.global_position, def["color"])
func _process_towers(delta: float) -> void:
for tower in towers_root.get_children():
var ft: float = tower.get_meta("fire_timer") - delta
tower.set_meta("fire_timer", ft)
if ft > 0.0: continue
var rng: float = tower.get_meta("range")
var target = _find_target(tower.global_position, rng)
if target == null: continue
tower.set_meta("fire_timer", tower.get_meta("fire_rate"))
var look_pos = target.global_position
look_pos.y = tower.global_position.y
tower.look_at(look_pos, Vector3.UP)
_fire(tower, target)
func _find_target(from: Vector3, range_val: float) -> Node3D:
var best: Node3D = null
var best_prog: float = -1.0
for enemy in enemies_root.get_children():
if not enemy.get_meta("alive", false): continue
var d = from.distance_to(enemy.global_position)
if d > range_val: continue
var prog = float(enemy.get_meta("wp_index")) + \
(1.0 - from.distance_to(PATH_WAYPOINTS[min(enemy.get_meta("wp_index"), PATH_WAYPOINTS.size()-1)]) / 20.0)
if prog > best_prog:
best_prog = prog
best = enemy
return best
func _fire(tower: Node3D, target: Node3D) -> void:
var proj = Node3D.new()
proj.name = "Proj"
proj.global_position = tower.global_position + Vector3(0, 0.8, 0)
projectiles_root.add_child(proj)
var pc: Color = tower.get_meta("proj_color")
var mesh_inst = MeshInstance3D.new()
var sph = SphereMesh.new()
sph.radius = 0.18
sph.height = 0.36
mesh_inst.mesh = sph
var mat = StandardMaterial3D.new()
mat.albedo_color = pc
mat.emission_enabled = true
mat.emission = pc
mat.emission_energy_multiplier = 2.0
mesh_inst.material_override = mat
proj.add_child(mesh_inst)
proj.set_meta("target", target)
proj.set_meta("damage", tower.get_meta("damage"))
proj.set_meta("speed", tower.get_meta("speed"))
proj.set_meta("slow", tower.get_meta("slow"))
proj.set_meta("slow_dur", tower.get_meta("slow_dur"))
proj.set_meta("splash", tower.get_meta("splash"))
proj.set_meta("tower_ref", tower)
proj.set_meta("alive", true)
func _process_projectiles(delta: float) -> void:
for proj in projectiles_root.get_children():
if not proj.get_meta("alive", true): continue
var target = proj.get_meta("target")
if not is_instance_valid(target) or not target.get_meta("alive", false):
proj.set_meta("alive", false)
proj.queue_free()
continue
var dir = (target.global_position + Vector3(0, 0.5, 0)) - proj.global_position
var dist = dir.length()
var spd: float = proj.get_meta("speed")
if dist < 0.4:
_on_hit(proj, target)
proj.set_meta("alive", false)
proj.queue_free()
else:
proj.global_position += dir.normalized() * spd * delta
func _on_hit(proj: Node3D, target: Node3D) -> void:
var dmg: int = proj.get_meta("damage")
var splash: float = proj.get_meta("splash")
var slow: float = proj.get_meta("slow")
var slow_d: float = proj.get_meta("slow_dur")
var tower = proj.get_meta("tower_ref")
_spawn_hit_effect(target.global_position, proj.get_meta("slow") > 0.0)
if splash > 0.0:
_spawn_explosion_effect(target.global_position)
for enemy in enemies_root.get_children():
if not enemy.get_meta("alive", false): continue
if enemy.global_position.distance_to(target.global_position) <= splash:
_deal_damage(enemy, dmg, tower, slow, slow_d)
else:
_deal_damage(target, dmg, tower, slow, slow_d)
func _deal_damage(enemy: Node3D, dmg: int, tower: Node3D, slow: float, slow_d: float) -> void:
if not enemy.get_meta("alive", false): return
var armor: float = enemy.get_meta("armor")
var real_dmg: int = max(1, int(dmg * (1.0 - armor)))
var hp: int = enemy.get_meta("hp") - real_dmg
enemy.set_meta("hp", hp)
if is_instance_valid(tower):
tower.set_meta("total_dmg", tower.get_meta("total_dmg") + real_dmg)
if slow > 0.0:
enemy.set_meta("slow_timer", slow_d)
if hp <= 0:
_kill_enemy(enemy, tower)
else:
_show_damage_number(enemy.global_position, real_dmg, slow > 0.0)
func _kill_enemy(enemy: Node3D, tower: Node3D) -> void:
if not enemy.get_meta("alive", false): return
enemy.set_meta("alive", false)
var reward: int = enemy.get_meta("reward")
gold += reward
score += reward * 3
if is_instance_valid(tower):
tower.set_meta("kills", tower.get_meta("kills") + 1)
_update_ui()
_spawn_death_effect(enemy.global_position, ENEMY_DEFS[enemy.get_meta("type")]["color"])
enemy.queue_free()
# ═══════════════════════════════════════════════════════════════════════════
func _input(event: InputEvent) -> void:
if game_state == "gameover" or game_state == "victory": return
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
var cell = _mouse_to_cell()
if cell != Vector3i(-999, 0, -999):
_place_tower(cell)
elif event.button_index == MOUSE_BUTTON_RIGHT:
var cell = _mouse_to_cell()
if placed_towers.has(cell):
_sell_tower(cell)
func _sell_tower(cell: Vector3i) -> void:
var tower = placed_towers[cell]
var refund: int = tower.get_meta("sell")
gold += refund
score += 1
placed_towers.erase(cell)
_spawn_place_effect(tower.global_position, Color(1.0, 0.8, 0.0))
tower.queue_free()
_update_ui()
# ═══════════════════════════════════════════════════════════════════════════
func _create_preview() -> void:
preview_node = Node3D.new()
preview_node.name = "Preview"
add_child(preview_node)
var mesh_inst = MeshInstance3D.new()
mesh_inst.name = "PreviewMesh"
var cyl = CylinderMesh.new()
cyl.top_radius = 0.4
cyl.bottom_radius = 0.45
cyl.height = 1.0
mesh_inst.mesh = cyl
preview_node.add_child(mesh_inst)
var rng_ring = _create_range_ring(6.0, Color.WHITE)
rng_ring.name = "PreviewRange"
preview_node.add_child(rng_ring)
func _update_preview() -> void:
if preview_node == null: return
var cell = _mouse_to_cell()
if cell == Vector3i(-999, 0, -999):
preview_node.visible = false
return
preview_node.visible = true
preview_node.global_position = Vector3(cell.x + 0.5, 0.0, cell.z + 0.5)
can_place = not placed_towers.has(cell) and not _is_path_cell(cell)
var def = TOWER_DEFS[selected_tower_type]
var mesh_inst: MeshInstance3D = preview_node.get_node("PreviewMesh")
var mat = StandardMaterial3D.new()
if can_place and gold >= def["cost"]:
mat.albedo_color = Color(def["color"].r, def["color"].g, def["color"].b, 0.55)
else:
mat.albedo_color = Color(1.0, 0.2, 0.2, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mesh_inst.material_override = mat
var rng_ring = preview_node.get_node_or_null("PreviewRange")
if rng_ring and rng_ring.get_meta("for_type", "") != selected_tower_type:
rng_ring.queue_free()
var new_ring = _create_range_ring(def["range"], def["color"])
new_ring.name = "PreviewRange"
new_ring.set_meta("for_type", selected_tower_type)
preview_node.add_child(new_ring)
# ═══════════════════════════════════════════════════════════════════════════
func _process_effects(delta: float) -> void:
for eff in effects_root.get_children():
var life: float = eff.get_meta("life", 0.0) - delta
if life <= 0.0:
eff.queue_free()
continue
eff.set_meta("life", life)
var total: float = eff.get_meta("total_life", 1.0)
var t = 1.0 - life / total
var vel: Vector3 = eff.get_meta("vel", Vector3.ZERO)
eff.global_position += vel * delta
vel.y -= 4.0 * delta
eff.set_meta("vel", vel)
var mesh = eff.get_node_or_null("M")
if mesh and mesh.material_override:
var a = (1.0 - t) * eff.get_meta("alpha", 1.0)
mesh.material_override.albedo_color.a = clamp(a, 0, 1)
func _spawn_hit_effect(pos: Vector3, is_slow: bool) -> void:
var n = Node3D.new()
n.global_position = pos + Vector3(0, 0.5, 0)
effects_root.add_child(n)
var m = MeshInstance3D.new(); m.name = "M"
var sph = SphereMesh.new(); sph.radius = 0.35; m.mesh = sph
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.6, 1.0, 1.0, 0.9) if is_slow else Color(1.0, 0.8, 0.3, 0.9)
mat.emission_enabled = true; mat.emission = mat.albedo_color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.material_override = mat
n.add_child(m)
n.set_meta("life", 0.18); n.set_meta("total_life", 0.18)
n.set_meta("vel", Vector3(randf_range(-1,1), 2.0, randf_range(-1,1)))
n.set_meta("alpha", 0.9)
func _spawn_death_effect(pos: Vector3, color: Color) -> void:
for i in range(8):
var n = Node3D.new()
n.global_position = pos + Vector3(0, 0.4, 0)
effects_root.add_child(n)
var m = MeshInstance3D.new(); m.name = "M"
var sph = SphereMesh.new(); sph.radius = 0.2; m.mesh = sph
var mat = StandardMaterial3D.new()
mat.albedo_color = color; mat.emission_enabled = true
mat.emission = color; mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.material_override = mat; n.add_child(m)
n.set_meta("life", 0.5 + randf()*0.4); n.set_meta("total_life", 0.9)
n.set_meta("vel", Vector3(randf_range(-3,3), randf_range(2,5), randf_range(-3,3)))
n.set_meta("alpha", 1.0)
func _spawn_explosion_effect(pos: Vector3) -> void:
for i in range(12):
var n = Node3D.new()
n.global_position = pos + Vector3(0, 0.3, 0)
effects_root.add_child(n)
var m = MeshInstance3D.new(); m.name = "M"
var sph = SphereMesh.new(); sph.radius = 0.3; m.mesh = sph
var mat = StandardMaterial3D.new()
var c = Color(1.0, randf_range(0.3, 0.7), 0.1)
mat.albedo_color = c; mat.emission_enabled = true; mat.emission = c
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.material_override = mat; n.add_child(m)
n.set_meta("life", 0.6); n.set_meta("total_life", 0.6)
n.set_meta("vel", Vector3(randf_range(-4,4), randf_range(1,6), randf_range(-4,4)))
n.set_meta("alpha", 1.0)
func _spawn_place_effect(pos: Vector3, color: Color) -> void:
for i in range(6):
var n = Node3D.new()
n.global_position = pos + Vector3(0, 0.2, 0)
effects_root.add_child(n)
var m = MeshInstance3D.new(); m.name = "M"
var sph = SphereMesh.new(); sph.radius = 0.15; m.mesh = sph
var mat = StandardMaterial3D.new()
mat.albedo_color = color; mat.emission_enabled = true; mat.emission = color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.material_override = mat; n.add_child(m)
n.set_meta("life", 0.45); n.set_meta("total_life", 0.45)
n.set_meta("vel", Vector3(randf_range(-2,2), randf_range(2,4), randf_range(-2,2)))
n.set_meta("alpha", 1.0)
var _dmg_labels: Array = []
func _show_damage_number(pos: Vector3, dmg: int, is_slow: bool) -> void:
var lbl = Label3D.new()
lbl.text = str(dmg)
lbl.font_size = 48
lbl.modulate = Color(0.4, 0.9, 1.0) if is_slow else Color(1.0, 0.9, 0.3)
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.global_position = pos + Vector3(randf_range(-0.3,0.3), 1.0, randf_range(-0.3,0.3))
effects_root.add_child(lbl)
var tween = create_tween()
tween.tween_property(lbl, "global_position", lbl.global_position + Vector3(0,1.5,0), 0.8)
tween.parallel().tween_property(lbl, "modulate:a", 0.0, 0.8)
tween.tween_callback(lbl.queue_free)
var _ui_flash_tween: Tween
func _flash_damage_ui() -> void:
if _ui_flash_tween: _ui_flash_tween.kill()
_ui_flash_tween = create_tween()
lbl_lives.modulate = Color(1, 0, 0)
_ui_flash_tween.tween_property(lbl_lives, "modulate", Color.WHITE, 0.5)
# ═══════════════════════════════════════════════════════════════════════════
func _create_tower_mesh(def: Dictionary) -> Node3D:
var root = Node3D.new()
var base_inst = MeshInstance3D.new()
var cyl = CylinderMesh.new()
cyl.top_radius = 0.3; cyl.bottom_radius = 0.45; cyl.height = 1.0
base_inst.mesh = cyl
var mat = StandardMaterial3D.new()
mat.albedo_color = def["color"]
mat.roughness = 0.4; mat.metallic = 0.6
base_inst.material_override = mat
base_inst.position = Vector3(0, 0.5, 0)
root.add_child(base_inst)
var top_inst = MeshInstance3D.new()
var sph = SphereMesh.new(); sph.radius = 0.28
top_inst.mesh = sph
var mat2 = StandardMaterial3D.new()
mat2.albedo_color = def["color"] * 1.3
mat2.emission_enabled = true; mat2.emission = def["color"]
mat2.emission_energy_multiplier = 0.6
mat2.roughness = 0.2; mat2.metallic = 0.8
top_inst.material_override = mat2
top_inst.position = Vector3(0, 1.15, 0)
root.add_child(top_inst)
return root
func _create_enemy_mesh(def: Dictionary) -> MeshInstance3D:
var m = MeshInstance3D.new()
var cap = CapsuleMesh.new()
cap.radius = def["size"] * 0.5
cap.height = def["size"] * 1.6
m.mesh = cap
var mat = StandardMaterial3D.new()
mat.albedo_color = def["color"]
mat.roughness = 0.5
m.material_override = mat
m.position = Vector3(0, def["size"] * 0.8, 0)
return m
func _create_hp_bar() -> Node3D:
var root = Node3D.new()
root.name = "HPBar"
root.position = Vector3(0, 2.0, 0)
var bg = MeshInstance3D.new()
var bg_mesh = BoxMesh.new(); bg_mesh.size = Vector3(1.0, 0.12, 0.04)
bg.mesh = bg_mesh
var bg_mat = StandardMaterial3D.new(); bg_mat.albedo_color = Color(0.2, 0.2, 0.2)
bg_mat.no_depth_test = true
bg.material_override = bg_mat; bg.name = "BG"
root.add_child(bg)
var bar = MeshInstance3D.new()
var bar_mesh = BoxMesh.new(); bar_mesh.size = Vector3(0.98, 0.10, 0.05)
bar.mesh = bar_mesh
var bar_mat = StandardMaterial3D.new(); bar_mat.albedo_color = Color(0.2, 0.9, 0.2)
bar_mat.no_depth_test = true; bar_mat.emission_enabled = true; bar_mat.emission = Color(0.1,0.5,0.1)
bar.material_override = bar_mat; bar.name = "Bar"
root.add_child(bar)
root.set_meta("billboard_hp", true)
return root
func _update_enemy_hp_bar(enemy: Node3D) -> void:
var hp_bar = enemy.get_meta("hp_bar") as Node3D
if hp_bar == null: return
var hp: int = enemy.get_meta("hp")
var max_hp: int = enemy.get_meta("max_hp")
var ratio = clamp(float(hp) / float(max_hp), 0.0, 1.0)
var bar = hp_bar.get_node_or_null("Bar") as MeshInstance3D
if bar == null: return
bar.scale.x = ratio
bar.position.x = (ratio - 1.0) * 0.5
var c: Color
if ratio > 0.5:
c = Color(1.0 - (ratio - 0.5) * 2.0, 1.0, 0.0)
else:
c = Color(1.0, ratio * 2.0, 0.0)
bar.material_override.albedo_color = c
bar.material_override.emission = c * 0.5
var cam = get_viewport().get_camera_3d()
if cam:
hp_bar.global_rotation = cam.global_rotation
func _create_range_ring(radius: float, color: Color) -> Node3D:
var root = Node3D.new()
root.name = "RangeRing"
var steps = 48
var points = PackedVector3Array()
for i in range(steps + 1):
var a = TAU * i / steps
points.append(Vector3(cos(a) * radius, 0.05, sin(a) * radius))
var imm = ImmediateMesh.new()
imm.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
for p in points: imm.surface_add_vertex(p)
imm.surface_end()
var mi = MeshInstance3D.new()
mi.mesh = imm
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(color.r, color.g, color.b, 0.6)
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mi.material_override = mat
root.add_child(mi)
return root
# ═══════════════════════════════════════════════════════════════════════════
func _build_path_visual() -> void:
for i in range(PATH_WAYPOINTS.size() - 1):
_draw_path_segment(PATH_WAYPOINTS[i], PATH_WAYPOINTS[i + 1])
func _draw_path_segment(a: Vector3, b: Vector3) -> void:
var mid = (a + b) * 0.5
var diff = b - a
var len = diff.length()
var mi = MeshInstance3D.new()
var bx = BoxMesh.new()
var is_x = abs(diff.x) > abs(diff.z)
if is_x:
bx.size = Vector3(len, 0.05, 1.8)
else:
bx.size = Vector3(1.8, 0.05, len)
mi.mesh = bx
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.55, 0.45, 0.30)
mat.roughness = 0.9
mi.material_override = mat
mi.global_position = Vector3(mid.x, -0.02, mid.z)
add_child(mi)
func _compute_path_cells() -> void:
path_cells.clear()
for i in range(PATH_WAYPOINTS.size() - 1):
var a = PATH_WAYPOINTS[i]
var b = PATH_WAYPOINTS[i + 1]
var dir = (b - a).normalized()
var len = a.distance_to(b)
var steps = int(len) + 2
for s in range(steps):
var p = a + dir * s
var cell = Vector3i(int(floor(p.x)), 0, int(floor(p.z)))
if not path_cells.has(cell):
path_cells.append(cell)
for dx in [-1, 0, 1]:
for dz in [-1, 0, 1]:
var nc = Vector3i(cell.x+dx, 0, cell.z+dz)
if not path_cells.has(nc):
var along_x = abs(dir.x) > abs(dir.z)
if along_x and dx == 0: path_cells.append(nc)
elif not along_x and dz == 0: path_cells.append(nc)
func _is_path_cell(cell: Vector3i) -> bool:
return path_cells.has(cell)
# ═══════════════════════════════════════════════════════════════════════════
func _mouse_to_cell() -> Vector3i:
var cam = get_viewport().get_camera_3d()
if cam == null: return Vector3i(-999, 0, -999)
var mouse = get_viewport().get_mouse_position()
var ray_from = cam.project_ray_origin(mouse)
var ray_dir = cam.project_ray_normal(mouse)
if abs(ray_dir.y) < 0.001: return Vector3i(-999, 0, -999)
var t = -ray_from.y / ray_dir.y
if t < 0: return Vector3i(-999, 0, -999)
var hit = ray_from + ray_dir * t
return Vector3i(int(floor(hit.x)), 0, int(floor(hit.z)))
# ═══════════════════════════════════════════════════════════════════════════
func _build_ui() -> void:
ui_layer = CanvasLayer.new()
ui_layer.name = "UI"
add_child(ui_layer)
var root_ctrl = Control.new()
root_ctrl.set_anchors_preset(Control.PRESET_FULL_RECT)
root_ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
ui_layer.add_child(root_ctrl)
var top_panel = PanelContainer.new()
top_panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
top_panel.custom_minimum_size = Vector2(0, 52)
root_ctrl.add_child(top_panel)
var top_hbox = HBoxContainer.new()
top_hbox.add_theme_constant_override("separation", 24)
top_panel.add_child(top_hbox)
lbl_gold = _make_label("💰 150", Color(1.0, 0.85, 0.2))
lbl_lives = _make_label("❤️ 20", Color(1.0, 0.4, 0.4))
lbl_wave = _make_label("ВОЛНА 1", Color(0.6, 1.0, 0.6))
lbl_score = _make_label("★ 0", Color(0.9, 0.9, 0.9))
top_hbox.add_child(lbl_gold)
top_hbox.add_child(lbl_lives)
top_hbox.add_child(lbl_wave)
top_hbox.add_child(lbl_score)
speed_btn = Button.new()
speed_btn.text = "⏩ x1"
speed_btn.custom_minimum_size = Vector2(80, 0)
speed_btn.pressed.connect(_toggle_speed)
top_hbox.add_child(speed_btn)
var bot_panel = PanelContainer.new()
bot_panel.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
bot_panel.custom_minimum_size = Vector2(0, 90)
root_ctrl.add_child(bot_panel)
var bot_vbox = VBoxContainer.new()
bot_panel.add_child(bot_vbox)
bar_container = HBoxContainer.new()
bar_container.add_theme_constant_override("separation", 8)
bar_container.alignment = BoxContainer.ALIGNMENT_CENTER
bot_vbox.add_child(bar_container)
for ttype in TOWER_DEFS.keys():
var def = TOWER_DEFS[ttype]
var btn = Button.new()
btn.custom_minimum_size = Vector2(120, 64)
btn.text = "[%s]\n%s\n💰%d" % [def["name"], def["desc"].substr(0, 20), def["cost"]]
btn.modulate = def["color"]
btn.tooltip_text = "%s\nУрон:%d Дальность:%.0f\nСкорострельность:%.1f" % [
def["desc"], def["damage"], def["range"], def["fire_rate"]]
btn.pressed.connect(_select_tower.bind(ttype))
bar_container.add_child(btn)
tower_buttons[ttype] = btn
var hint = Label.new()
hint.text = "ЛКМ — поставить башню | ПКМ — продать башню"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.modulate = Color(0.7, 0.7, 0.7)
bot_vbox.add_child(hint)
btn_start = Button.new()
btn_start.text = "▶ ВОЛНА 1 / %d" % WAVES.size()
btn_start.custom_minimum_size = Vector2(260, 54)
btn_start.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
btn_start.position.y = -140
btn_start.pressed.connect(_start_wave)
root_ctrl.add_child(btn_start)
lbl_msg = Label.new()
lbl_msg.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl_msg.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl_msg.set_anchors_preset(Control.PRESET_CENTER)
lbl_msg.custom_minimum_size = Vector2(420, 160)
lbl_msg.position = Vector2(-210, -80)
lbl_msg.add_theme_font_size_override("font_size", 22)
lbl_msg.modulate = Color(1, 1, 0.5)
lbl_msg.visible = false
root_ctrl.add_child(lbl_msg)
_select_tower("basic")
func _make_label(text: String, color: Color) -> Label:
var l = Label.new()
l.text = text
l.modulate = color
l.add_theme_font_size_override("font_size", 18)
l.custom_minimum_size = Vector2(120, 0)
return l
func _select_tower(ttype: String) -> void:
selected_tower_type = ttype
for t in tower_buttons.keys():
tower_buttons[t].modulate = TOWER_DEFS[t]["color"] if t != ttype else Color.WHITE
if preview_node:
var rng_ring = preview_node.get_node_or_null("PreviewRange")
if rng_ring: rng_ring.queue_free()
var def = TOWER_DEFS[ttype]
var new_ring = _create_range_ring(def["range"], def["color"])
new_ring.name = "PreviewRange"
new_ring.set_meta("for_type", ttype)
preview_node.add_child(new_ring)
func _toggle_speed() -> void:
if game_speed == 1.0:
game_speed = 2.0; speed_btn.text = "⏩ x2"
elif game_speed == 2.0:
game_speed = 3.0; speed_btn.text = "⏩ x3"
else:
game_speed = 1.0; speed_btn.text = "⏩ x1"
func _update_ui() -> void:
if lbl_gold: lbl_gold.text = "💰 %d" % gold
if lbl_lives: lbl_lives.text = "❤️ %d" % lives
if lbl_wave: lbl_wave.text = "ВОЛНА %d/%d" % [wave_index + 1, WAVES.size()]
if lbl_score: lbl_score.text = "★ %d" % score
if bar_container:
for ttype in tower_buttons.keys():
tower_buttons[ttype].disabled = gold < TOWER_DEFS[ttype]["cost"]
func _show_message(text: String, auto_hide: float = 0.0) -> void:
if lbl_msg == null: return
lbl_msg.text = text
lbl_msg.visible = true
if auto_hide > 0.0:
await get_tree().create_timer(auto_hide).timeout
lbl_msg.visible = false
# ═══════════════════════════════════════════════════════════════════════════
func _game_over() -> void:
game_state = "gameover"
btn_start.visible = false
_show_message("💀 ПОРАЖЕНИЕ\nОчки: %d\n\nПерезапусти сцену (F5)" % score)
func _victory() -> void:
game_state = "victory"
btn_start.visible = false
_show_message("🏆 ПОБЕДА!\nВсе волны отбиты!\nОчки: %d\n\nПерезапусти (F5)" % score)