inital commit
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
signal died(points: int)
|
||||
|
||||
enum State { CHASING, FLYING, STUNNED, DEAD }
|
||||
|
||||
var move_speed: float = 3.0
|
||||
var health: int = 30
|
||||
var damage_to_player: int = 8
|
||||
var score_value: int = 10
|
||||
var wall_damage_mult: float = 1.8
|
||||
var chain_factor: float = 0.65
|
||||
var stun_time: float = 0.5
|
||||
var base_scale: float = 1.0
|
||||
|
||||
var state: State = State.CHASING
|
||||
var fly_vel: Vector3 = Vector3.ZERO
|
||||
var stun_timer: float = 0.0
|
||||
var contact_timer: float = 0.0
|
||||
var target: Node3D = null
|
||||
|
||||
var mesh_node: MeshInstance3D
|
||||
var mat: StandardMaterial3D
|
||||
|
||||
var COLOR_CHASE = Color(1.0, 0.28, 0.18)
|
||||
var COLOR_FLY = Color(1.0, 0.85, 0.1)
|
||||
var COLOR_STUN = Color(0.55, 0.55, 0.65)
|
||||
const CONTACT_CD = 0.7
|
||||
const AIR_FRICTION = 0.86
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
_build_mesh()
|
||||
_build_collider()
|
||||
|
||||
func setup(type: String, wave: int) -> void:
|
||||
match type:
|
||||
"slime":
|
||||
move_speed = 2.8 + wave * 0.12
|
||||
health = 28 + wave * 4
|
||||
score_value = 10
|
||||
damage_to_player = 8
|
||||
"bat":
|
||||
move_speed = 5.5 + wave * 0.15
|
||||
health = 14 + wave * 2
|
||||
score_value = 15
|
||||
damage_to_player = 6
|
||||
base_scale = 0.7
|
||||
mesh_node.scale = Vector3(0.7, 0.7, 0.7)
|
||||
COLOR_CHASE = Color(0.6, 0.2, 0.8)
|
||||
mat.albedo_color = COLOR_CHASE
|
||||
"ogre":
|
||||
move_speed = 1.8 + wave * 0.08
|
||||
health = 80 + wave * 12
|
||||
score_value = 25
|
||||
damage_to_player = 18
|
||||
base_scale = 1.5
|
||||
mesh_node.scale = Vector3(1.5, 1.5, 1.5)
|
||||
COLOR_CHASE = Color(0.3, 0.7, 0.3)
|
||||
mat.albedo_color = COLOR_CHASE
|
||||
|
||||
func _build_mesh() -> void:
|
||||
mesh_node = MeshInstance3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(0.85, 0.85, 0.85)
|
||||
mesh_node.mesh = box
|
||||
mesh_node.position.y = 0.425
|
||||
mat = StandardMaterial3D.new()
|
||||
mat.albedo_color = COLOR_CHASE
|
||||
mat.roughness = 0.8
|
||||
mesh_node.material_override = mat
|
||||
add_child(mesh_node)
|
||||
|
||||
func _build_collider() -> void:
|
||||
var col := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = Vector3(0.85, 0.85, 0.85)
|
||||
col.shape = shape
|
||||
col.position.y = 0.425
|
||||
add_child(col)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
match state:
|
||||
State.CHASING: _chase(delta)
|
||||
State.FLYING: _fly(delta)
|
||||
State.STUNNED: _stun_tick(delta)
|
||||
State.DEAD: pass
|
||||
|
||||
func _chase(delta: float) -> void:
|
||||
if not is_instance_valid(target):
|
||||
return
|
||||
contact_timer = max(0.0, contact_timer - delta)
|
||||
|
||||
var diff := target.global_position - global_position
|
||||
diff.y = 0.0
|
||||
var dist := diff.length()
|
||||
|
||||
if dist < 1.0 and contact_timer <= 0.0:
|
||||
contact_timer = CONTACT_CD
|
||||
if target.has_method("take_damage"):
|
||||
target.take_damage(damage_to_player)
|
||||
|
||||
if dist > 0.05:
|
||||
var dir := diff.normalized()
|
||||
velocity.x = dir.x * move_speed
|
||||
velocity.z = dir.z * move_speed
|
||||
rotation.y = lerp_angle(rotation.y, atan2(dir.x, dir.z), 8.0 * delta)
|
||||
velocity.y = 0.0
|
||||
move_and_slide()
|
||||
|
||||
func _fly(delta: float) -> void:
|
||||
var speed_now := Vector2(fly_vel.x, fly_vel.z).length()
|
||||
velocity = fly_vel
|
||||
velocity.y = 0.0
|
||||
move_and_slide()
|
||||
|
||||
var hit_wall := false
|
||||
for i in get_slide_collision_count():
|
||||
var col := get_slide_collision(i)
|
||||
var col3d := col.get_collider() as Node3D
|
||||
if col3d == null:
|
||||
continue
|
||||
|
||||
if col3d.has_meta("is_wall"):
|
||||
var dmg := int(speed_now * wall_damage_mult)
|
||||
_take_hit(dmg)
|
||||
_wall_impact_effect()
|
||||
fly_vel = Vector3.ZERO
|
||||
velocity = Vector3.ZERO
|
||||
_enter_stun()
|
||||
hit_wall = true
|
||||
break
|
||||
elif col3d.is_in_group("enemies") and col3d != self:
|
||||
var chain_dir := col3d.global_position - global_position
|
||||
chain_dir.y = 0.0
|
||||
if chain_dir.length() > 0.01:
|
||||
col3d.call("receive_kick", chain_dir.normalized(), speed_now * chain_factor)
|
||||
elif col3d.is_in_group("player"):
|
||||
col3d.call("take_damage", int(speed_now * 0.6))
|
||||
|
||||
if not hit_wall:
|
||||
fly_vel = velocity
|
||||
fly_vel.y = 0.0
|
||||
fly_vel *= pow(AIR_FRICTION, delta * 60.0)
|
||||
if Vector2(fly_vel.x, fly_vel.z).length() < 0.4:
|
||||
_enter_chase()
|
||||
|
||||
# Spin while airborne
|
||||
mesh_node.rotation.y += delta * 10.0
|
||||
|
||||
func _stun_tick(delta: float) -> void:
|
||||
velocity = Vector3.ZERO
|
||||
stun_timer -= delta
|
||||
if stun_timer <= 0.0:
|
||||
_enter_chase()
|
||||
|
||||
func receive_kick(direction: Vector3, force: float) -> void:
|
||||
if state == State.DEAD:
|
||||
return
|
||||
fly_vel = direction * force
|
||||
fly_vel.y = 0.0
|
||||
state = State.FLYING
|
||||
mat.albedo_color = COLOR_FLY
|
||||
var tw := create_tween()
|
||||
tw.tween_property(mesh_node, "scale:y", base_scale * 0.35, 0.06)
|
||||
tw.tween_property(mesh_node, "scale:y", base_scale, 0.18)
|
||||
|
||||
func _enter_stun() -> void:
|
||||
state = State.STUNNED
|
||||
stun_timer = stun_time
|
||||
mat.albedo_color = COLOR_STUN
|
||||
var bs := base_scale
|
||||
var tw := create_tween()
|
||||
tw.tween_property(mesh_node, "scale", Vector3(bs * 1.6, bs * 0.25, bs * 1.6), 0.07)
|
||||
tw.tween_property(mesh_node, "scale", Vector3(bs, bs, bs), 0.22)
|
||||
|
||||
func _enter_chase() -> void:
|
||||
state = State.CHASING
|
||||
mat.albedo_color = COLOR_CHASE
|
||||
|
||||
func _take_hit(dmg: int) -> void:
|
||||
if state == State.DEAD:
|
||||
return
|
||||
health -= dmg
|
||||
if health <= 0:
|
||||
_die()
|
||||
|
||||
func _wall_impact_effect() -> void:
|
||||
var tw := create_tween()
|
||||
tw.tween_property(mat, "albedo_color", Color.WHITE, 0.04)
|
||||
tw.tween_property(mat, "albedo_color", COLOR_STUN, 0.12)
|
||||
|
||||
func _die() -> void:
|
||||
if state == State.DEAD:
|
||||
return
|
||||
state = State.DEAD
|
||||
set_physics_process(false)
|
||||
emit_signal("died", score_value)
|
||||
var tw := create_tween()
|
||||
tw.tween_property(self, "scale", Vector3(2.0, 0.05, 2.0), 0.18)
|
||||
tw.tween_property(self, "scale", Vector3(0.0, 0.0, 0.0), 0.1)
|
||||
tw.tween_callback(queue_free)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ujlvt8u71hg1
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
extends Node3D
|
||||
|
||||
const PLAYER_SCENE := preload("res://scenes/Player.tscn")
|
||||
const ENEMY_SCENE := preload("res://scenes/Enemy.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
|
||||
|
||||
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()
|
||||
_start_game()
|
||||
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)
|
||||
|
||||
# ─── 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 = 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)
|
||||
|
||||
# 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 _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 _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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjqcstgcoedos
|
||||
@@ -0,0 +1,231 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
signal died
|
||||
signal health_changed(current: int, maximum: int)
|
||||
|
||||
@export var move_speed: float = 7.0
|
||||
@export var kick_range: float = 3.5
|
||||
@export var kick_force: float = 22.0
|
||||
@export var kick_cooldown: float = 0.6
|
||||
@export var kick_angle: float = 120.0
|
||||
@export var max_health: int = 100
|
||||
|
||||
var health: int = max_health
|
||||
var kick_timer: float = 0.0
|
||||
var invincible_timer: float = 0.0
|
||||
var is_alive: bool = true
|
||||
var last_move_dir: Vector3 = Vector3.FORWARD
|
||||
|
||||
var mesh_node: MeshInstance3D
|
||||
var player_mat: StandardMaterial3D
|
||||
var indicator_node: MeshInstance3D
|
||||
var indicator_mat: StandardMaterial3D
|
||||
|
||||
const IFRAMES_DURATION := 0.6
|
||||
const BASE_COLOR := Color(0.2, 0.55, 1.0)
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("player")
|
||||
_build_visuals()
|
||||
_build_collider()
|
||||
|
||||
func _build_visuals() -> void:
|
||||
mesh_node = MeshInstance3D.new()
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = 0.4
|
||||
capsule.height = 1.0
|
||||
mesh_node.mesh = capsule
|
||||
mesh_node.position.y = 0.5
|
||||
player_mat = StandardMaterial3D.new()
|
||||
player_mat.albedo_color = BASE_COLOR
|
||||
player_mat.roughness = 0.6
|
||||
player_mat.metallic = 0.2
|
||||
mesh_node.material_override = player_mat
|
||||
add_child(mesh_node)
|
||||
|
||||
# Kick arc indicator (sector/fan mesh on floor)
|
||||
indicator_node = MeshInstance3D.new()
|
||||
indicator_node.mesh = _make_kick_arc_mesh()
|
||||
indicator_node.position.y = 0.02
|
||||
indicator_mat = StandardMaterial3D.new()
|
||||
indicator_mat.albedo_color = Color(1.0, 0.85, 0.1, 0.2)
|
||||
indicator_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
indicator_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
indicator_mat.no_depth_test = true
|
||||
indicator_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
indicator_node.material_override = indicator_mat
|
||||
add_child(indicator_node)
|
||||
|
||||
func _make_kick_arc_mesh() -> ArrayMesh:
|
||||
var verts := PackedVector3Array()
|
||||
var half_rad: float = deg_to_rad(kick_angle * 0.5)
|
||||
const SEGS := 24
|
||||
verts.append(Vector3(0.0, 0.0, 0.0))
|
||||
for i in range(SEGS + 1):
|
||||
var t: float = float(i) / float(SEGS)
|
||||
var a: float = lerpf(-half_rad, half_rad, t)
|
||||
verts.append(Vector3(sin(a) * kick_range, 0.0, -cos(a) * kick_range))
|
||||
|
||||
var indices := PackedInt32Array()
|
||||
for i in range(SEGS):
|
||||
indices.append(0)
|
||||
indices.append(i + 1)
|
||||
indices.append(i + 2)
|
||||
|
||||
var arrays: Array = []
|
||||
arrays.resize(Mesh.ARRAY_MAX)
|
||||
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||
arrays[Mesh.ARRAY_INDEX] = indices
|
||||
|
||||
var mesh := ArrayMesh.new()
|
||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
||||
return mesh
|
||||
|
||||
func _build_collider() -> void:
|
||||
var col := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.4
|
||||
shape.height = 1.0
|
||||
col.shape = shape
|
||||
col.position.y = 0.5
|
||||
add_child(col)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
|
||||
_handle_movement(delta)
|
||||
_handle_kick(delta)
|
||||
_handle_iframes(delta)
|
||||
|
||||
if Input.is_action_just_pressed("ui_accept") and kick_timer <= 0.0:
|
||||
_do_kick()
|
||||
|
||||
func _handle_movement(delta: float) -> void:
|
||||
var input_x: float = (
|
||||
float(Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT)) -
|
||||
float(Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT))
|
||||
)
|
||||
var input_z: float = (
|
||||
float(Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_DOWN)) -
|
||||
float(Input.is_key_pressed(KEY_W) or Input.is_key_pressed(KEY_UP))
|
||||
)
|
||||
|
||||
# Camera-relative movement: camera is independent (mouse-controlled), no feedback loop
|
||||
var cam := get_viewport().get_camera_3d()
|
||||
if (abs(input_x) > 0.0 or abs(input_z) > 0.0) and cam != null:
|
||||
var cam_fwd := -cam.global_transform.basis.z
|
||||
cam_fwd.y = 0.0
|
||||
if cam_fwd.length() > 0.01:
|
||||
cam_fwd = cam_fwd.normalized()
|
||||
else:
|
||||
cam_fwd = Vector3(0.0, 0.0, -1.0)
|
||||
|
||||
var cam_right := cam.global_transform.basis.x
|
||||
cam_right.y = 0.0
|
||||
if cam_right.length() > 0.01:
|
||||
cam_right = cam_right.normalized()
|
||||
else:
|
||||
cam_right = Vector3(1.0, 0.0, 0.0)
|
||||
|
||||
var move := cam_fwd * (-input_z) + cam_right * input_x
|
||||
if move.length() > 0.01:
|
||||
move = move.normalized()
|
||||
velocity.x = move.x * move_speed
|
||||
velocity.z = move.z * move_speed
|
||||
last_move_dir = move
|
||||
var target_y: float = atan2(-move.x, -move.z)
|
||||
rotation.y = lerp_angle(rotation.y, target_y, 16.0 * delta)
|
||||
else:
|
||||
velocity.x = move_toward(velocity.x, 0.0, move_speed * 12.0 * delta)
|
||||
velocity.z = move_toward(velocity.z, 0.0, move_speed * 12.0 * delta)
|
||||
|
||||
velocity.y = 0.0
|
||||
move_and_slide()
|
||||
|
||||
func _handle_kick(delta: float) -> void:
|
||||
kick_timer = max(0.0, kick_timer - delta)
|
||||
var t: float = clamp(1.0 - kick_timer / kick_cooldown, 0.0, 1.0)
|
||||
indicator_mat.albedo_color.a = 0.05 + t * 0.3
|
||||
|
||||
func _handle_iframes(delta: float) -> void:
|
||||
if invincible_timer > 0.0:
|
||||
invincible_timer -= delta
|
||||
if invincible_timer > 0.0:
|
||||
mesh_node.visible = fmod(invincible_timer * 10.0, 1.0) > 0.5
|
||||
else:
|
||||
mesh_node.visible = true
|
||||
|
||||
func _do_kick() -> void:
|
||||
kick_timer = kick_cooldown
|
||||
|
||||
var forward := -global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
if forward.length() > 0.01:
|
||||
forward = forward.normalized()
|
||||
else:
|
||||
forward = Vector3(0, 0, -1)
|
||||
|
||||
var half_cos: float = cos(deg_to_rad(kick_angle * 0.5))
|
||||
|
||||
var enemies := get_tree().get_nodes_in_group("enemies")
|
||||
var kicked_any := false
|
||||
for e in enemies:
|
||||
if not is_instance_valid(e):
|
||||
continue
|
||||
var en := e as Node3D
|
||||
if en == null:
|
||||
continue
|
||||
var diff := en.global_position - global_position
|
||||
diff.y = 0.0
|
||||
var dist := diff.length()
|
||||
if dist < 0.1 or dist > kick_range:
|
||||
continue
|
||||
var dir_to_enemy := diff / dist
|
||||
if dir_to_enemy.dot(forward) >= half_cos:
|
||||
en.call("receive_kick", dir_to_enemy, kick_force)
|
||||
kicked_any = true
|
||||
|
||||
if kicked_any:
|
||||
_squish_effect()
|
||||
|
||||
func _squish_effect() -> void:
|
||||
var tw := create_tween()
|
||||
tw.tween_property(mesh_node, "scale", Vector3(1.3, 0.55, 1.3), 0.07)
|
||||
tw.tween_property(mesh_node, "scale", Vector3(1.0, 1.0, 1.0), 0.18)
|
||||
|
||||
func take_damage(amount: int) -> void:
|
||||
if not is_alive or invincible_timer > 0.0:
|
||||
return
|
||||
invincible_timer = IFRAMES_DURATION
|
||||
health = max(0, health - amount)
|
||||
emit_signal("health_changed", health, max_health)
|
||||
|
||||
var tw := create_tween()
|
||||
tw.tween_property(player_mat, "albedo_color", Color.RED, 0.08)
|
||||
tw.tween_property(player_mat, "albedo_color", BASE_COLOR, 0.25)
|
||||
|
||||
if health <= 0:
|
||||
_die()
|
||||
|
||||
func _die() -> void:
|
||||
is_alive = false
|
||||
emit_signal("died")
|
||||
var tw := create_tween()
|
||||
tw.tween_property(self, "scale", Vector3(2.0, 0.0, 2.0), 0.35)
|
||||
|
||||
func apply_upgrade(id: String) -> void:
|
||||
match id:
|
||||
"kick_force":
|
||||
kick_force += 6.0
|
||||
"kick_range":
|
||||
kick_range += 0.7
|
||||
indicator_node.mesh = _make_kick_arc_mesh()
|
||||
"kick_cooldown":
|
||||
kick_cooldown = max(0.12, kick_cooldown - 0.09)
|
||||
"move_speed":
|
||||
move_speed += 1.2
|
||||
"max_health":
|
||||
max_health += 30
|
||||
health = min(health + 30, max_health)
|
||||
emit_signal("health_changed", health, max_health)
|
||||
@@ -0,0 +1 @@
|
||||
uid://beqb88qvr2lk0
|
||||
Reference in New Issue
Block a user