490 lines
16 KiB
GDScript
490 lines
16 KiB
GDScript
extends Node3D
|
|
|
|
const PLAYER_SCENE := preload("res://scenes/Player.tscn")
|
|
const ENEMY_SCENE := preload("res://scenes/Enemy.tscn")
|
|
const ROCK_SCENE := preload("res://scenes/Rock.tscn")
|
|
|
|
const ARENA := 14.0
|
|
const WALL_T := 1.2
|
|
const CAM_DIST := 8.0
|
|
const MOUSE_SENS := 0.18
|
|
const PITCH_MIN := 5.0
|
|
const PITCH_MAX := 70.0
|
|
const SPAWN_TIME := 10
|
|
|
|
var cam_yaw: float = 0.0
|
|
var cam_pitch: float = 28.0
|
|
|
|
@onready var spawn_timer: Timer = $SpawnTimer
|
|
|
|
var player: CharacterBody3D
|
|
var camera: Camera3D
|
|
var wave: int = 1
|
|
var score: int = 0
|
|
var kills: int = 0
|
|
var kills_for_next: int = 10
|
|
var game_active: bool = false
|
|
var upgrading: bool = false
|
|
|
|
# UI nodes
|
|
var canvas: CanvasLayer
|
|
var score_label: Label
|
|
var wave_label: Label
|
|
var hp_bar: ColorRect
|
|
var hp_bar_bg: ColorRect
|
|
var progress_bar: ColorRect
|
|
var progress_bg: ColorRect
|
|
var upgrade_panel: Panel
|
|
var gameover_panel: Panel
|
|
|
|
func _ready() -> void:
|
|
_create_environment()
|
|
_create_arena()
|
|
_create_camera()
|
|
_create_ui()
|
|
_spawn_player()
|
|
_spawn_rocks()
|
|
_start_game()
|
|
add_to_group("main")
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
var motion := event as InputEventMouseMotion
|
|
if motion != null and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
cam_yaw -= motion.relative.x * MOUSE_SENS
|
|
cam_pitch += motion.relative.y * MOUSE_SENS
|
|
cam_pitch = clampf(cam_pitch, PITCH_MIN, PITCH_MAX)
|
|
|
|
if event.is_action_pressed("ui_cancel"):
|
|
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
else:
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
|
|
# ─── Environment ──────────────────────────────────────────────────────────────
|
|
|
|
func _create_environment() -> void:
|
|
var env := Environment.new()
|
|
env.background_mode = Environment.BG_COLOR
|
|
env.background_color = Color(0.04, 0.04, 0.08)
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
env.ambient_light_color = Color(0.35, 0.35, 0.5)
|
|
env.ambient_light_energy = 0.6
|
|
var we := WorldEnvironment.new()
|
|
we.environment = env
|
|
add_child(we)
|
|
|
|
var sun := DirectionalLight3D.new()
|
|
sun.rotation_degrees = Vector3(-55, -25, 0)
|
|
sun.light_energy = 1.8
|
|
sun.shadow_enabled = true
|
|
add_child(sun)
|
|
|
|
# ─── Arena ────────────────────────────────────────────────────────────────────
|
|
|
|
func _create_arena() -> void:
|
|
var floor_mat := StandardMaterial3D.new()
|
|
floor_mat.albedo_color = Color(0.12, 0.12, 0.18)
|
|
floor_mat.roughness = 1.0
|
|
|
|
# Floor mesh (visual only)
|
|
var fm := MeshInstance3D.new()
|
|
var plane := PlaneMesh.new()
|
|
plane.size = Vector2(ARENA * 2, ARENA * 2)
|
|
plane.subdivide_width = 8
|
|
plane.subdivide_depth = 8
|
|
fm.mesh = plane
|
|
fm.material_override = floor_mat
|
|
add_child(fm)
|
|
|
|
# Floor collider
|
|
var fb := StaticBody3D.new()
|
|
var fc := CollisionShape3D.new()
|
|
var fs := BoxShape3D.new()
|
|
fs.size = Vector3(ARENA * 2, 0.2, ARENA * 2)
|
|
fc.shape = fs
|
|
fb.position.y = -0.1
|
|
fb.add_child(fc)
|
|
add_child(fb)
|
|
|
|
# Grid lines on floor
|
|
_draw_grid()
|
|
|
|
# Four walls
|
|
var wall_mat := StandardMaterial3D.new()
|
|
wall_mat.albedo_color = Color(0.28, 0.28, 0.42)
|
|
wall_mat.roughness = 0.9
|
|
wall_mat.metallic = 0.1
|
|
|
|
_make_wall(Vector3(0, 0.5, -(ARENA + WALL_T * 0.5)),
|
|
Vector3(ARENA * 2 + WALL_T * 2, 1.0, WALL_T), wall_mat)
|
|
_make_wall(Vector3(0, 0.5, (ARENA + WALL_T * 0.5)),
|
|
Vector3(ARENA * 2 + WALL_T * 2, 1.0, WALL_T), wall_mat)
|
|
_make_wall(Vector3(-(ARENA + WALL_T * 0.5), 0.5, 0),
|
|
Vector3(WALL_T, 1.0, ARENA * 2), wall_mat)
|
|
_make_wall(Vector3( (ARENA + WALL_T * 0.5), 0.5, 0),
|
|
Vector3(WALL_T, 1.0, ARENA * 2), wall_mat)
|
|
|
|
func _make_wall(pos: Vector3, size: Vector3, mat: StandardMaterial3D) -> void:
|
|
var body := StaticBody3D.new()
|
|
body.position = pos
|
|
body.set_meta("is_wall", true)
|
|
|
|
var col := CollisionShape3D.new()
|
|
var shape := BoxShape3D.new()
|
|
shape.size = size
|
|
col.shape = shape
|
|
body.add_child(col)
|
|
|
|
var msh := MeshInstance3D.new()
|
|
var box := BoxMesh.new()
|
|
box.size = size
|
|
msh.mesh = box
|
|
msh.material_override = mat
|
|
body.add_child(msh)
|
|
|
|
add_child(body)
|
|
|
|
func _draw_grid() -> void:
|
|
# Subtle grid as thin quads
|
|
var grid_mat := StandardMaterial3D.new()
|
|
grid_mat.albedo_color = Color(0.2, 0.2, 0.3, 0.5)
|
|
grid_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
grid_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
var step := 4.0
|
|
var n := int(ARENA / step)
|
|
for i in range(-n, n + 1):
|
|
for axis in [0, 1]:
|
|
var msh := MeshInstance3D.new()
|
|
var box := BoxMesh.new()
|
|
if axis == 0:
|
|
box.size = Vector3(0.05, 0.01, ARENA * 2)
|
|
msh.position = Vector3(i * step, 0.005, 0)
|
|
else:
|
|
box.size = Vector3(ARENA * 2, 0.01, 0.05)
|
|
msh.position = Vector3(0, 0.005, i * step)
|
|
msh.mesh = box
|
|
msh.material_override = grid_mat
|
|
add_child(msh)
|
|
|
|
# ─── Camera ───────────────────────────────────────────────────────────────────
|
|
|
|
func _create_camera() -> void:
|
|
camera = Camera3D.new()
|
|
camera.fov = 70.0
|
|
add_child(camera)
|
|
|
|
func _process(delta: float) -> void:
|
|
if is_instance_valid(player):
|
|
var yaw_r: float = deg_to_rad(cam_yaw)
|
|
var pitch_r: float = deg_to_rad(cam_pitch)
|
|
var offset := Vector3(
|
|
sin(yaw_r) * cos(pitch_r) * CAM_DIST,
|
|
sin(pitch_r) * CAM_DIST,
|
|
cos(yaw_r) * cos(pitch_r) * CAM_DIST
|
|
)
|
|
var look_at_pos := player.global_position + Vector3(0, 0.8, 0)
|
|
camera.global_position = camera.global_position.lerp(look_at_pos + offset, 14.0 * delta)
|
|
camera.look_at(look_at_pos, Vector3.UP)
|
|
if Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
|
|
player.set_aim_direction(deg_to_rad(cam_yaw))
|
|
|
|
# ─── Rocks ────────────────────────────────────────────────────────────────────
|
|
|
|
func _spawn_rocks() -> void:
|
|
for i in range(10):
|
|
var rock := ROCK_SCENE.instantiate()
|
|
add_child(rock)
|
|
var angle := randf() * TAU
|
|
var dist := randf_range(3.5, ARENA - 2.0)
|
|
rock.position = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)
|
|
|
|
# ─── Player ───────────────────────────────────────────────────────────────────
|
|
|
|
func _spawn_player() -> void:
|
|
player = PLAYER_SCENE.instantiate() as CharacterBody3D
|
|
player.position = Vector3(0, 0, 0)
|
|
player.connect("died", _on_player_died)
|
|
player.connect("health_changed", _on_health_changed)
|
|
add_child(player)
|
|
|
|
# ─── Game flow ────────────────────────────────────────────────────────────────
|
|
|
|
func _start_game() -> void:
|
|
game_active = true
|
|
wave = 1
|
|
score = 0
|
|
kills = 0
|
|
kills_for_next = 10
|
|
_update_labels()
|
|
spawn_timer.wait_time = 1.4
|
|
spawn_timer.connect("timeout", _on_spawn_timer)
|
|
spawn_timer.start()
|
|
|
|
func _on_spawn_timer() -> void:
|
|
if not game_active or upgrading:
|
|
return
|
|
_spawn_enemy()
|
|
spawn_timer.wait_time = SPAWN_TIME # max(0.25, 1.4 - wave * 0.07)
|
|
|
|
func _spawn_enemy() -> void:
|
|
var enemy := ENEMY_SCENE.instantiate() as CharacterBody3D
|
|
add_child(enemy)
|
|
|
|
# Pick type based on wave
|
|
var pool: Array[String] = ["slime"]
|
|
if wave >= 4: pool.append("bat")
|
|
if wave >= 7: pool.append("ogre")
|
|
var type: String = pool[randi() % pool.size()]
|
|
enemy.setup(type, wave)
|
|
enemy.target = player
|
|
enemy.connect("died", _on_enemy_died)
|
|
enemy.connect("merged", _on_enemy_merged)
|
|
|
|
# Spawn at random edge
|
|
var side := randi() % 4
|
|
var r := randf_range(-(ARENA - 1.0), ARENA - 1.0)
|
|
match side:
|
|
0: enemy.position = Vector3(r, 0, -(ARENA - 0.5))
|
|
1: enemy.position = Vector3(r, 0, (ARENA - 0.5))
|
|
2: enemy.position = Vector3(-(ARENA - 0.5), 0, r)
|
|
3: enemy.position = Vector3( (ARENA - 0.5), 0, r)
|
|
|
|
func add_bonus_score(amount: int) -> void:
|
|
score += amount
|
|
_update_labels()
|
|
|
|
func _on_enemy_died(points: int) -> void:
|
|
score += points
|
|
kills += 1
|
|
_update_labels()
|
|
_update_progress()
|
|
|
|
if kills >= kills_for_next:
|
|
kills = 0
|
|
kills_for_next = int(kills_for_next * 1.6)
|
|
wave += 1
|
|
_show_upgrade()
|
|
|
|
func _spawn_upgraded_enemy(pos: Vector3, type: String, level: int, w: int) -> CharacterBody3D:
|
|
var enemy := ENEMY_SCENE.instantiate() as CharacterBody3D
|
|
add_child(enemy)
|
|
enemy.setup(type, w)
|
|
enemy.target = player
|
|
enemy.enemy_level = level + 1
|
|
enemy.global_position = pos
|
|
enemy.connect("died", _on_enemy_died)
|
|
enemy.connect("merged", _on_enemy_merged)
|
|
var tw := enemy.create_tween()
|
|
var s: float = 1.0 + level * 0.3
|
|
tw.tween_property(enemy.mesh_node, "scale", Vector3(s, s, s), 0.2)
|
|
var col_shape := enemy.get_node_or_null("CollisionShape3D") as CollisionShape3D
|
|
if col_shape != null and col_shape.shape != null:
|
|
var s3d: BoxShape3D = col_shape.shape as BoxShape3D
|
|
var old_size: Vector3 = s3d.size
|
|
col_shape.shape = BoxShape3D.new()
|
|
(col_shape.shape as BoxShape3D).size = old_size * s
|
|
var color := Color(1.0, 1.0, 0.5) if level > 2 else Color(1.0, 0.9, 0.3)
|
|
tw.tween_property(enemy.mat, "albedo_color", color, 0.25)
|
|
#var bs := scale
|
|
#tw.tween_property(enemy.mesh_node, "scale", Vector3(bs * 1.6, bs * 0.25, bs * 1.6), 0.07)
|
|
#tw.tween_property(enemy.mesh_node, "scale", Vector3(bs, bs, bs), 0.22)
|
|
return enemy
|
|
|
|
func _on_enemy_merged(_upgrade: bool) -> void:
|
|
pass
|
|
|
|
func _on_player_died() -> void:
|
|
game_active = false
|
|
spawn_timer.stop()
|
|
_show_gameover()
|
|
|
|
func _on_health_changed(cur: int, mx: int) -> void:
|
|
hp_bar.size.x = 200.0 * float(cur) / float(mx)
|
|
|
|
# ─── Upgrades ─────────────────────────────────────────────────────────────────
|
|
|
|
const UPGRADES := [
|
|
{"id": "kick_force", "name": "💥 Stronger Kick", "desc": "Enemies fly farther"},
|
|
{"id": "kick_range", "name": "🌐 Wider Kick", "desc": "Bigger kick area"},
|
|
{"id": "kick_cooldown", "name": "⚡ Faster Kick", "desc": "Kick more often"},
|
|
{"id": "move_speed", "name": "💨 Fleet Foot", "desc": "Move faster"},
|
|
{"id": "max_health", "name": "❤️ Vitality", "desc": "+30 max HP"},
|
|
]
|
|
|
|
func _show_upgrade() -> void:
|
|
upgrading = true
|
|
get_tree().paused = true
|
|
upgrade_panel.visible = true
|
|
|
|
# Pick 3 random upgrades
|
|
var pool: Array = UPGRADES.duplicate()
|
|
pool.shuffle()
|
|
var choices: Array = pool.slice(0, 3)
|
|
|
|
for i in range(3):
|
|
var btn := upgrade_panel.get_node("VBox/Btn%d" % i) as Button
|
|
if i < choices.size():
|
|
var upg: Dictionary = choices[i]
|
|
btn.text = "%s\n%s" % [upg["name"], upg["desc"]]
|
|
btn.visible = true
|
|
# Disconnect old signals
|
|
for conn in btn.get_signal_connection_list("pressed"):
|
|
btn.disconnect("pressed", conn["callable"])
|
|
var uid: String = upg["id"]
|
|
btn.connect("pressed", _pick_upgrade.bind(uid))
|
|
else:
|
|
btn.visible = false
|
|
|
|
func _pick_upgrade(id: String) -> void:
|
|
player.apply_upgrade(id)
|
|
upgrade_panel.visible = false
|
|
get_tree().paused = false
|
|
upgrading = false
|
|
_update_labels()
|
|
|
|
func _show_gameover() -> void:
|
|
gameover_panel.visible = true
|
|
var lbl := gameover_panel.get_node("VBox/ScoreLabel") as Label
|
|
lbl.text = "Score: %d\nWave: %d" % [score, wave]
|
|
|
|
func _restart() -> void:
|
|
get_tree().paused = false
|
|
get_tree().reload_current_scene()
|
|
|
|
# ─── UI ───────────────────────────────────────────────────────────────────────
|
|
|
|
func _create_ui() -> void:
|
|
canvas = CanvasLayer.new()
|
|
canvas.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
add_child(canvas)
|
|
|
|
_make_hud()
|
|
_make_upgrade_panel()
|
|
_make_gameover_panel()
|
|
|
|
func _make_hud() -> void:
|
|
# Score
|
|
score_label = _label(Vector2(12, 10), "Score: 0", 22)
|
|
# Wave
|
|
wave_label = _label(Vector2(12, 38), "Wave: 1", 22)
|
|
|
|
# HP bar
|
|
_label(Vector2(12, 68), "HP", 16)
|
|
hp_bar_bg = _crect(Vector2(12, 88), Vector2(200, 16), Color(0.25, 0.04, 0.04))
|
|
hp_bar = _crect(Vector2(12, 88), Vector2(200, 16), Color(0.9, 0.15, 0.15))
|
|
|
|
# Kill progress toward next upgrade
|
|
_label(Vector2(12, 110), "Next upgrade", 16)
|
|
progress_bg = _crect(Vector2(12, 130), Vector2(200, 10), Color(0.1, 0.1, 0.25))
|
|
progress_bar = _crect(Vector2(12, 130), Vector2(0, 10), Color(0.4, 0.8, 1.0))
|
|
|
|
func _make_upgrade_panel() -> void:
|
|
upgrade_panel = Panel.new()
|
|
upgrade_panel.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
upgrade_panel.visible = false
|
|
canvas.add_child(upgrade_panel)
|
|
|
|
# Center with explicit anchors (420x300)
|
|
upgrade_panel.anchor_left = 0.5
|
|
upgrade_panel.anchor_right = 0.5
|
|
upgrade_panel.anchor_top = 0.5
|
|
upgrade_panel.anchor_bottom = 0.5
|
|
upgrade_panel.offset_left = -210.0
|
|
upgrade_panel.offset_right = 210.0
|
|
upgrade_panel.offset_top = -150.0
|
|
upgrade_panel.offset_bottom = 150.0
|
|
|
|
var vbox := VBoxContainer.new()
|
|
vbox.name = "VBox"
|
|
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
vbox.add_theme_constant_override("separation", 12)
|
|
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
upgrade_panel.add_child(vbox)
|
|
|
|
var title := Label.new()
|
|
title.text = "LEVEL UP! Choose an upgrade:"
|
|
title.add_theme_font_size_override("font_size", 20)
|
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox.add_child(title)
|
|
|
|
for i in range(3):
|
|
var btn := Button.new()
|
|
btn.name = "Btn%d" % i
|
|
btn.custom_minimum_size = Vector2(380, 60)
|
|
btn.add_theme_font_size_override("font_size", 16)
|
|
btn.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
vbox.add_child(btn)
|
|
|
|
func _make_gameover_panel() -> void:
|
|
gameover_panel = Panel.new()
|
|
gameover_panel.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
gameover_panel.visible = false
|
|
canvas.add_child(gameover_panel)
|
|
|
|
# Center with explicit anchors (360x240)
|
|
gameover_panel.anchor_left = 0.5
|
|
gameover_panel.anchor_right = 0.5
|
|
gameover_panel.anchor_top = 0.5
|
|
gameover_panel.anchor_bottom = 0.5
|
|
gameover_panel.offset_left = -180.0
|
|
gameover_panel.offset_right = 180.0
|
|
gameover_panel.offset_top = -120.0
|
|
gameover_panel.offset_bottom = 120.0
|
|
|
|
var vbox := VBoxContainer.new()
|
|
vbox.name = "VBox"
|
|
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
vbox.add_theme_constant_override("separation", 16)
|
|
gameover_panel.add_child(vbox)
|
|
|
|
var title := Label.new()
|
|
title.text = "GAME OVER"
|
|
title.add_theme_font_size_override("font_size", 32)
|
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox.add_child(title)
|
|
|
|
var score_lbl := Label.new()
|
|
score_lbl.name = "ScoreLabel"
|
|
score_lbl.add_theme_font_size_override("font_size", 20)
|
|
score_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox.add_child(score_lbl)
|
|
|
|
var restart_btn := Button.new()
|
|
restart_btn.text = "Play Again"
|
|
restart_btn.add_theme_font_size_override("font_size", 18)
|
|
restart_btn.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
restart_btn.connect("pressed", _restart)
|
|
vbox.add_child(restart_btn)
|
|
|
|
# ─── UI helpers ───────────────────────────────────────────────────────────────
|
|
|
|
func _label(pos: Vector2, text: String, size: int) -> Label:
|
|
var lbl := Label.new()
|
|
lbl.position = pos
|
|
lbl.text = text
|
|
lbl.add_theme_font_size_override("font_size", size)
|
|
lbl.add_theme_color_override("font_color", Color.WHITE)
|
|
lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
|
|
lbl.add_theme_constant_override("shadow_offset_x", 2)
|
|
lbl.add_theme_constant_override("shadow_offset_y", 2)
|
|
canvas.add_child(lbl)
|
|
return lbl
|
|
|
|
func _crect(pos: Vector2, sz: Vector2, col: Color) -> ColorRect:
|
|
var r := ColorRect.new()
|
|
r.position = pos
|
|
r.size = sz
|
|
r.color = col
|
|
canvas.add_child(r)
|
|
return r
|
|
|
|
func _update_labels() -> void:
|
|
score_label.text = "Score: %d" % score
|
|
wave_label.text = "Wave: %d" % wave
|
|
|
|
func _update_progress() -> void:
|
|
var t := float(kills) / float(kills_for_next)
|
|
progress_bar.size.x = 200.0 * t
|