1004 lines
34 KiB
GDScript
1004 lines
34 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 STICK_SCENE := preload("res://scenes/Stick.tscn")
|
|
const LEVEL_SCENE := preload("res://scenes/Level.tscn")
|
|
|
|
const CAM_DIST := 8.0
|
|
const MOUSE_SENS := 0.18
|
|
const PITCH_MIN := 5.0
|
|
const PITCH_MAX := 70.0
|
|
const SPAWN_TIME := 5
|
|
|
|
var arena_size: float = 14.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
|
|
|
|
# Tutorial
|
|
var tutorial_canvas: CanvasLayer
|
|
var tutorial_image: TextureRect
|
|
var tutorial_hint: Label
|
|
var tutorial_hint_ready: bool = false
|
|
var tutorial_active: bool = false
|
|
var tutorial_on_dismiss: Callable = Callable()
|
|
var shown_tutorials: Dictionary = {}
|
|
|
|
# UI nodes
|
|
var canvas: CanvasLayer
|
|
var score_label: Label
|
|
var wave_label: Label
|
|
var tier_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
|
|
|
|
# Equipment slots
|
|
var equip_fills: Array[ColorRect] = []
|
|
var equip_labels: Array[Label] = []
|
|
var _equip_prev_tiers: Array[int] = [-1, -1, -1]
|
|
|
|
# Boss phase
|
|
var boss_active: bool = false
|
|
var first_boss_spawned: bool = false
|
|
var boss_timer: float = 90.0
|
|
var portal_node: Node3D = null
|
|
var boss_timer_label: Label
|
|
var boss_hint_label: Label
|
|
var win_panel: Panel
|
|
|
|
func _ready() -> void:
|
|
_spawn_level()
|
|
_create_camera()
|
|
_create_ui()
|
|
_create_tutorial_overlay()
|
|
_spawn_player()
|
|
_spawn_rocks()
|
|
_spawn_sticks()
|
|
add_to_group("main")
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
show_tutorial("Tutorial_StartGame", _start_game)
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if tutorial_active:
|
|
return
|
|
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
|
|
|
|
# ─── Level ────────────────────────────────────────────────────────────────────
|
|
|
|
func _spawn_level() -> void:
|
|
var level := LEVEL_SCENE.instantiate()
|
|
add_child(level)
|
|
var sz = level.get("arena_size")
|
|
if sz != null:
|
|
arena_size = float(sz)
|
|
|
|
# ─── 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))
|
|
_update_tier_label()
|
|
|
|
if boss_active:
|
|
boss_timer = max(0.0, boss_timer - delta)
|
|
var mins := int(boss_timer) / 60
|
|
var secs := int(boss_timer) % 60
|
|
boss_timer_label.text = "%d:%02d" % [mins, secs]
|
|
if boss_timer <= 10.0:
|
|
boss_timer_label.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
|
|
if boss_timer <= 0.0:
|
|
_trigger_time_up()
|
|
return
|
|
if is_instance_valid(portal_node):
|
|
var area := portal_node.get_node_or_null("PortalArea") as Area3D
|
|
if area != null:
|
|
for body in area.get_overlapping_bodies():
|
|
if body.get("enemy_level") == 3:
|
|
var fv = body.get("fly_vel")
|
|
if fv != null:
|
|
var spd := Vector2((fv as Vector3).x, (fv as Vector3).z).length()
|
|
if spd >= 25.0:
|
|
_trigger_win()
|
|
return
|
|
|
|
# ─── Rocks ────────────────────────────────────────────────────────────────────
|
|
|
|
var rocks_on_field: int = 0
|
|
var rocks_pending: int = 0
|
|
|
|
func _get_rock_limit() -> int:
|
|
return mini(3 + (wave + 1) / 2, 7)
|
|
|
|
func _spawn_rocks() -> void:
|
|
var limit := _get_rock_limit()
|
|
for i in range(limit):
|
|
_spawn_single_rock()
|
|
|
|
var sticks_on_field: int = 0
|
|
var sticks_pending: int = 0
|
|
const STICK_LIMIT := 3
|
|
|
|
func _spawn_sticks() -> void:
|
|
for i in range(STICK_LIMIT):
|
|
_spawn_single_stick()
|
|
|
|
func _spawn_single_stick() -> void:
|
|
var stick := STICK_SCENE.instantiate()
|
|
stick.position = _safe_item_position()
|
|
add_child(stick)
|
|
stick.connect("destroyed", _on_stick_destroyed)
|
|
sticks_on_field += 1
|
|
|
|
func _on_stick_destroyed() -> void:
|
|
sticks_on_field = maxi(0, sticks_on_field - 1)
|
|
if not game_active:
|
|
return
|
|
if sticks_on_field + sticks_pending < STICK_LIMIT:
|
|
sticks_pending += 1
|
|
await get_tree().create_timer(GameSettings.item_respawn_delay).timeout
|
|
|
|
sticks_pending -= 1
|
|
if game_active:
|
|
_spawn_single_stick()
|
|
|
|
func _spawn_single_rock() -> void:
|
|
var rock := ROCK_SCENE.instantiate()
|
|
rock.position = _safe_item_position()
|
|
add_child(rock)
|
|
rock.connect("destroyed", _on_rock_destroyed)
|
|
rocks_on_field += 1
|
|
|
|
func _safe_item_position() -> Vector3:
|
|
var player_pos := player.global_position if is_instance_valid(player) else Vector3.ZERO
|
|
for _attempt in range(30):
|
|
var angle := randf() * TAU
|
|
var dist := randf_range(4.0, arena_size - 2.0)
|
|
var pos := Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)
|
|
if player_pos.distance_to(pos) < 4.5:
|
|
continue
|
|
var clear := true
|
|
for r in get_tree().get_nodes_in_group("kickable"):
|
|
if (r as Node3D).global_position.distance_to(pos) < 1.5:
|
|
clear = false
|
|
break
|
|
if clear:
|
|
return pos
|
|
var a := randf() * TAU
|
|
return Vector3(cos(a) * (arena_size - 2.5), 0.0, sin(a) * (arena_size - 2.5))
|
|
|
|
func _on_rock_destroyed() -> void:
|
|
rocks_on_field = maxi(0, rocks_on_field - 1)
|
|
if not game_active:
|
|
return
|
|
if rocks_on_field + rocks_pending < _get_rock_limit():
|
|
rocks_pending += 1
|
|
await get_tree().create_timer(GameSettings.item_respawn_delay).timeout
|
|
rocks_pending -= 1
|
|
if game_active:
|
|
_spawn_single_rock()
|
|
|
|
func _check_rock_slots() -> void:
|
|
var gap := _get_rock_limit() - rocks_on_field - rocks_pending
|
|
for i in range(gap):
|
|
_spawn_single_rock()
|
|
|
|
# ─── 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
|
|
Enemy.first_leather_spawned = false
|
|
Enemy.first_iron_spawned = false
|
|
Enemy.first_essence_spawned = false
|
|
_update_labels()
|
|
spawn_timer.wait_time = GameSettings.enemy_spawn_interval
|
|
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 = GameSettings.enemy_spawn_interval
|
|
|
|
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)
|
|
if type == "ogre" and not first_boss_spawned:
|
|
first_boss_spawned = true
|
|
_start_boss_phase()
|
|
|
|
# Spawn at random edge
|
|
var side := randi() % 4
|
|
var r := randf_range(-(arena_size - 1.0), arena_size - 1.0)
|
|
match side:
|
|
0: enemy.position = Vector3(r, 0, -(arena_size - 0.5))
|
|
1: enemy.position = Vector3(r, 0, (arena_size - 0.5))
|
|
2: enemy.position = Vector3(-(arena_size - 0.5), 0, r)
|
|
3: enemy.position = Vector3( (arena_size - 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
|
|
_check_rock_slots()
|
|
_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
|
|
enemy.kick_tier = level
|
|
enemy.toughness_tier = level
|
|
enemy.call("_update_label")
|
|
enemy.call("_apply_mesh", level)
|
|
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
|
|
if level >= 3 and not first_boss_spawned:
|
|
first_boss_spawned = true
|
|
_start_boss_phase()
|
|
return enemy
|
|
|
|
func _on_enemy_merged(_upgrade: bool) -> void:
|
|
pass
|
|
|
|
func _on_player_died() -> void:
|
|
game_active = false
|
|
if boss_active:
|
|
boss_active = false
|
|
boss_timer_label.visible = false
|
|
boss_hint_label.visible = false
|
|
if is_instance_valid(portal_node):
|
|
portal_node.queue_free()
|
|
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()
|
|
|
|
# ─── Boss phase ───────────────────────────────────────────────────────────────
|
|
|
|
func _start_boss_phase() -> void:
|
|
if boss_active or not game_active:
|
|
return
|
|
boss_active = true
|
|
boss_timer = 120.0
|
|
boss_timer_label.visible = true
|
|
boss_hint_label.visible = true
|
|
_spawn_portal()
|
|
|
|
func _spawn_portal() -> void:
|
|
portal_node = Node3D.new()
|
|
add_child(portal_node)
|
|
|
|
var player_pos := player.global_position if is_instance_valid(player) else Vector3.ZERO
|
|
var best_pos := Vector3(arena_size - 1.5, 0.0, 0.0)
|
|
var best_dist := 0.0
|
|
for _i in range(16):
|
|
var angle := randf() * TAU
|
|
var cand := Vector3(cos(angle), 0.0, sin(angle)) * (arena_size - 1.5)
|
|
var d := cand.distance_to(player_pos)
|
|
if d > best_dist:
|
|
best_dist = d
|
|
best_pos = cand
|
|
portal_node.global_position = best_pos
|
|
|
|
var light := OmniLight3D.new()
|
|
light.light_color = Color(0.5, 0.1, 1.0)
|
|
light.omni_range = 7.0
|
|
light.light_energy = 3.0
|
|
light.position.y = 1.0
|
|
portal_node.add_child(light)
|
|
|
|
var ring := MeshInstance3D.new()
|
|
var torus := TorusMesh.new()
|
|
torus.inner_radius = 1.0
|
|
torus.outer_radius = 1.65
|
|
var ring_mat := StandardMaterial3D.new()
|
|
ring_mat.albedo_color = Color(0.55, 0.1, 1.0)
|
|
ring_mat.emission_enabled = true
|
|
ring_mat.emission = Color(0.6, 0.2, 1.0)
|
|
ring_mat.emission_energy = 3.5
|
|
ring_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
torus.material = ring_mat
|
|
ring.mesh = torus
|
|
ring.position.y = 0.05
|
|
portal_node.add_child(ring)
|
|
ring.create_tween().set_loops().tween_property(ring, "rotation:y", TAU, 2.5)
|
|
|
|
var disc := MeshInstance3D.new()
|
|
var disc_mesh := CylinderMesh.new()
|
|
disc_mesh.top_radius = 1.0
|
|
disc_mesh.bottom_radius = 1.0
|
|
disc_mesh.height = 0.05
|
|
var disc_mat := StandardMaterial3D.new()
|
|
disc_mat.albedo_color = Color(0.25, 0.0, 0.7, 0.65)
|
|
disc_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
disc_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
disc_mat.emission_enabled = true
|
|
disc_mat.emission = Color(0.4, 0.1, 1.0)
|
|
disc_mat.emission_energy = 1.5
|
|
disc_mesh.material = disc_mat
|
|
disc.mesh = disc_mesh
|
|
disc.position.y = 0.03
|
|
portal_node.add_child(disc)
|
|
var tw_pulse := portal_node.create_tween().set_loops()
|
|
tw_pulse.tween_method(func(e: float): disc_mat.emission_energy = e, 0.8, 3.0, 0.7)
|
|
tw_pulse.tween_method(func(e: float): disc_mat.emission_energy = e, 3.0, 0.8, 0.7)
|
|
|
|
var area := Area3D.new()
|
|
area.name = "PortalArea"
|
|
var col := CollisionShape3D.new()
|
|
var cyl := CylinderShape3D.new()
|
|
cyl.radius = 1.8
|
|
cyl.height = 3.0
|
|
col.shape = cyl
|
|
col.position.y = 1.5
|
|
area.add_child(col)
|
|
portal_node.add_child(area)
|
|
|
|
var lbl := Label3D.new()
|
|
lbl.text = "PORTAL\nPush Ogre Here"
|
|
lbl.position = Vector3(0, 2.6, 0)
|
|
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
lbl.font_size = 34
|
|
lbl.outline_size = 6
|
|
lbl.modulate = Color(0.85, 0.55, 1.0)
|
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
portal_node.add_child(lbl)
|
|
|
|
FX.merge_smoke(best_pos + Vector3(0, 0.5, 0), self)
|
|
|
|
func _trigger_win() -> void:
|
|
if not boss_active:
|
|
return
|
|
boss_active = false
|
|
game_active = false
|
|
spawn_timer.stop()
|
|
boss_timer_label.visible = false
|
|
boss_hint_label.visible = false
|
|
if is_instance_valid(portal_node):
|
|
portal_node.queue_free()
|
|
get_tree().paused = true
|
|
win_panel.visible = true
|
|
(win_panel.get_node("VBox/ScoreLabel") as Label).text = "Score: %d\nWave: %d" % [score, wave]
|
|
|
|
func _trigger_time_up() -> void:
|
|
if not boss_active:
|
|
return
|
|
boss_active = false
|
|
game_active = false
|
|
spawn_timer.stop()
|
|
boss_timer_label.visible = false
|
|
boss_hint_label.visible = false
|
|
if is_instance_valid(portal_node):
|
|
portal_node.queue_free()
|
|
_show_gameover()
|
|
|
|
# ─── Tutorial ─────────────────────────────────────────────────────────────────
|
|
|
|
func _create_tutorial_overlay() -> void:
|
|
tutorial_canvas = CanvasLayer.new()
|
|
tutorial_canvas.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
tutorial_canvas.visible = false
|
|
add_child(tutorial_canvas)
|
|
|
|
var bg := ColorRect.new()
|
|
bg.color = Color(0.0, 0.0, 0.0, 0.78)
|
|
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
tutorial_canvas.add_child(bg)
|
|
|
|
tutorial_image = TextureRect.new()
|
|
tutorial_image.anchor_left = 0.02
|
|
tutorial_image.anchor_right = 0.98
|
|
tutorial_image.anchor_top = 0.02
|
|
tutorial_image.anchor_bottom = 0.91
|
|
tutorial_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
tutorial_image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
tutorial_canvas.add_child(tutorial_image)
|
|
|
|
tutorial_hint = Label.new()
|
|
tutorial_hint.anchor_left = 0.5
|
|
tutorial_hint.anchor_right = 0.5
|
|
tutorial_hint.anchor_top = 0.93
|
|
tutorial_hint.offset_left = -280
|
|
tutorial_hint.offset_right = 280
|
|
tutorial_hint.text = "Нажмите ЛКМ чтобы продолжить"
|
|
tutorial_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
tutorial_hint.add_theme_font_size_override("font_size", 22)
|
|
tutorial_hint.add_theme_color_override("font_color", Color.WHITE)
|
|
tutorial_hint.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.9))
|
|
tutorial_hint.add_theme_constant_override("shadow_offset_x", 2)
|
|
tutorial_hint.add_theme_constant_override("shadow_offset_y", 2)
|
|
tutorial_hint.visible = false
|
|
tutorial_canvas.add_child(tutorial_hint)
|
|
|
|
var click_cap := Control.new()
|
|
click_cap.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
click_cap.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
click_cap.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
click_cap.connect("gui_input", _on_tutorial_input)
|
|
tutorial_canvas.add_child(click_cap)
|
|
|
|
func _on_tutorial_input(event: InputEvent) -> void:
|
|
if not tutorial_hint_ready:
|
|
return
|
|
var mb := event as InputEventMouseButton
|
|
if mb != null and mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed:
|
|
_dismiss_tutorial()
|
|
|
|
func _set_enemies_paused(paused: bool) -> void:
|
|
var mode := Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_PAUSABLE
|
|
for e in get_tree().get_nodes_in_group("enemies"):
|
|
(e as Node).process_mode = mode
|
|
spawn_timer.paused = paused
|
|
|
|
func _set_player_paused(paused: bool) -> void:
|
|
var mode := Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_PAUSABLE
|
|
for e in get_tree().get_nodes_in_group("player"):
|
|
(e as Node).process_mode = mode
|
|
|
|
func show_tutorial(key: String, on_dismiss: Callable = Callable()) -> void:
|
|
if shown_tutorials.get(key, false):
|
|
if on_dismiss.is_valid():
|
|
on_dismiss.call()
|
|
return
|
|
shown_tutorials[key] = true
|
|
var path := "res://assets/%s.jpeg" % key
|
|
tutorial_image.texture = load(path) if ResourceLoader.exists(path) else null
|
|
tutorial_on_dismiss = on_dismiss
|
|
tutorial_hint_ready = false
|
|
tutorial_hint.visible = false
|
|
tutorial_canvas.visible = true
|
|
tutorial_active = true
|
|
_set_enemies_paused(true)
|
|
_set_player_paused(true)
|
|
await get_tree().create_timer(3.0).timeout
|
|
tutorial_hint.visible = true
|
|
tutorial_hint_ready = true
|
|
|
|
func _dismiss_tutorial() -> void:
|
|
tutorial_canvas.visible = false
|
|
tutorial_active = false
|
|
_set_enemies_paused(false)
|
|
_set_player_paused(false)
|
|
if tutorial_on_dismiss.is_valid():
|
|
tutorial_on_dismiss.call()
|
|
|
|
# ─── 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()
|
|
_make_boss_ui()
|
|
_make_win_panel()
|
|
|
|
func _make_hud() -> void:
|
|
score_label = _label(Vector2(16, 12), "Score: 0", 30)
|
|
wave_label = _label(Vector2(16, 50), "Wave: 1", 30)
|
|
_make_recipe_panel()
|
|
|
|
func _make_recipe_panel() -> void:
|
|
const INGREDIENT_NAMES := {
|
|
"rock": "Rock",
|
|
"stick": "Stick",
|
|
"leather": "Leather",
|
|
"iron": "Iron",
|
|
"metal_plate":"Iron Plate",
|
|
}
|
|
const RESULT_NAMES := {
|
|
"LeatherBoots": "Leather Boots",
|
|
"Boulder": "Boulder",
|
|
"StickArmor": "Stick Armor",
|
|
"LeatherArmor": "Leather Armor",
|
|
"PlateArmor": "Plate Armor",
|
|
"WoodenShield": "Wooden Shield",
|
|
"IronShield": "Iron Shield",
|
|
"MetalArmor": "Metal Armor",
|
|
}
|
|
|
|
const PAD_H := 10
|
|
const PAD_V := 8
|
|
const SEP := 4
|
|
const TITLE_H := 22
|
|
const ROW_H := 20
|
|
var n := MergeRecipes._list.size()
|
|
var content_h := TITLE_H + SEP + n * ROW_H + (n - 1) * SEP
|
|
var panel_h := content_h + PAD_V * 2 + 10
|
|
const PANEL_W := 260
|
|
|
|
var panel := Panel.new()
|
|
var sb := StyleBoxFlat.new()
|
|
sb.bg_color = Color(0.0, 0.0, 0.0, 0.55)
|
|
sb.corner_radius_top_left = 6
|
|
sb.corner_radius_top_right = 6
|
|
sb.corner_radius_bottom_left = 6
|
|
sb.corner_radius_bottom_right = 6
|
|
panel.add_theme_stylebox_override("panel", sb)
|
|
panel.anchor_left = 1.0
|
|
panel.anchor_right = 1.0
|
|
panel.anchor_top = 0.0
|
|
panel.anchor_bottom = 0.0
|
|
panel.offset_left = -(PANEL_W + 10)
|
|
panel.offset_right = -10
|
|
panel.offset_top = 10
|
|
panel.offset_bottom = 10 + panel_h
|
|
canvas.add_child(panel)
|
|
|
|
var margin := MarginContainer.new()
|
|
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
margin.add_theme_constant_override("margin_left", PAD_H)
|
|
margin.add_theme_constant_override("margin_right", PAD_H)
|
|
margin.add_theme_constant_override("margin_top", PAD_V)
|
|
margin.add_theme_constant_override("margin_bottom", PAD_V)
|
|
panel.add_child(margin)
|
|
|
|
var vbox := VBoxContainer.new()
|
|
vbox.add_theme_constant_override("separation", SEP)
|
|
margin.add_child(vbox)
|
|
|
|
var title := Label.new()
|
|
title.text = "Рецепты"
|
|
title.add_theme_font_size_override("font_size", 17)
|
|
title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.5))
|
|
title.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
|
|
title.add_theme_constant_override("shadow_offset_x", 1)
|
|
title.add_theme_constant_override("shadow_offset_y", 1)
|
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox.add_child(title)
|
|
|
|
for recipe in MergeRecipes._list:
|
|
var a: String = INGREDIENT_NAMES.get(recipe["ingredients"][0], recipe["ingredients"][0])
|
|
var b: String = INGREDIENT_NAMES.get(recipe["ingredients"][1], recipe["ingredients"][1])
|
|
var scene_name: String = (recipe["result_scene"] as String).get_file().get_basename()
|
|
var result: String = RESULT_NAMES.get(scene_name, scene_name)
|
|
var row := Label.new()
|
|
row.text = "%s + %s → %s" % [a, b, result]
|
|
row.add_theme_font_size_override("font_size", 15)
|
|
row.add_theme_color_override("font_color", Color.WHITE)
|
|
row.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
|
|
row.add_theme_constant_override("shadow_offset_x", 1)
|
|
row.add_theme_constant_override("shadow_offset_y", 1)
|
|
vbox.add_child(row)
|
|
|
|
_label(Vector2(16, 92), "HP", 20)
|
|
hp_bar_bg = _crect(Vector2(16, 118), Vector2(260, 20), Color(0.25, 0.04, 0.04))
|
|
hp_bar = _crect(Vector2(16, 118), Vector2(260, 20), Color(0.9, 0.15, 0.15))
|
|
|
|
_label(Vector2(16, 146), "Next upgrade", 20)
|
|
progress_bg = _crect(Vector2(16, 172), Vector2(260, 14), Color(0.1, 0.1, 0.25))
|
|
progress_bar = _crect(Vector2(16, 172), Vector2(0, 14), Color(0.4, 0.8, 1.0))
|
|
|
|
tier_label = _label(Vector2(16, 194), "", 20)
|
|
|
|
_make_equipment_slots()
|
|
|
|
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)
|
|
|
|
func _make_boss_ui() -> void:
|
|
boss_timer_label = Label.new()
|
|
boss_timer_label.anchor_left = 0.5
|
|
boss_timer_label.anchor_right = 0.5
|
|
boss_timer_label.anchor_top = 0.0
|
|
boss_timer_label.offset_left = -80
|
|
boss_timer_label.offset_right = 80
|
|
boss_timer_label.offset_top = 8
|
|
boss_timer_label.offset_bottom = 54
|
|
boss_timer_label.text = "1:30"
|
|
boss_timer_label.add_theme_font_size_override("font_size", 38)
|
|
boss_timer_label.add_theme_color_override("font_color", Color(0.8, 0.3, 1.0))
|
|
boss_timer_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.9))
|
|
boss_timer_label.add_theme_constant_override("shadow_offset_x", 2)
|
|
boss_timer_label.add_theme_constant_override("shadow_offset_y", 2)
|
|
boss_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
boss_timer_label.visible = false
|
|
canvas.add_child(boss_timer_label)
|
|
|
|
boss_hint_label = Label.new()
|
|
boss_hint_label.anchor_left = 0.5
|
|
boss_hint_label.anchor_right = 0.5
|
|
boss_hint_label.anchor_top = 0.0
|
|
boss_hint_label.offset_left = -220
|
|
boss_hint_label.offset_right = 220
|
|
boss_hint_label.offset_top = 54
|
|
boss_hint_label.offset_bottom = 80
|
|
boss_hint_label.text = "Push the Ogre into the Portal!"
|
|
boss_hint_label.add_theme_font_size_override("font_size", 17)
|
|
boss_hint_label.add_theme_color_override("font_color", Color(0.85, 0.7, 1.0))
|
|
boss_hint_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.8))
|
|
boss_hint_label.add_theme_constant_override("shadow_offset_x", 1)
|
|
boss_hint_label.add_theme_constant_override("shadow_offset_y", 1)
|
|
boss_hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
boss_hint_label.visible = false
|
|
canvas.add_child(boss_hint_label)
|
|
|
|
func _make_win_panel() -> void:
|
|
win_panel = Panel.new()
|
|
win_panel.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
win_panel.visible = false
|
|
canvas.add_child(win_panel)
|
|
|
|
win_panel.anchor_left = 0.5
|
|
win_panel.anchor_right = 0.5
|
|
win_panel.anchor_top = 0.5
|
|
win_panel.anchor_bottom = 0.5
|
|
win_panel.offset_left = -200.0
|
|
win_panel.offset_right = 200.0
|
|
win_panel.offset_top = -140.0
|
|
win_panel.offset_bottom = 140.0
|
|
|
|
var vbox2 := VBoxContainer.new()
|
|
vbox2.name = "VBox"
|
|
vbox2.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
vbox2.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
vbox2.add_theme_constant_override("separation", 16)
|
|
win_panel.add_child(vbox2)
|
|
|
|
var title2 := Label.new()
|
|
title2.text = "VICTORY!"
|
|
title2.add_theme_font_size_override("font_size", 42)
|
|
title2.add_theme_color_override("font_color", Color(1.0, 0.85, 0.1))
|
|
title2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox2.add_child(title2)
|
|
|
|
var score_lbl2 := Label.new()
|
|
score_lbl2.name = "ScoreLabel"
|
|
score_lbl2.add_theme_font_size_override("font_size", 20)
|
|
score_lbl2.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
vbox2.add_child(score_lbl2)
|
|
|
|
var btn2 := Button.new()
|
|
btn2.text = "Play Again"
|
|
btn2.add_theme_font_size_override("font_size", 18)
|
|
btn2.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
btn2.connect("pressed", _restart)
|
|
vbox2.add_child(btn2)
|
|
|
|
# ─── Equipment slots ──────────────────────────────────────────────────────────
|
|
|
|
const _EQUIP_SLOT_SIZE := 64
|
|
const _EQUIP_SLOT_GAP := 10
|
|
const _EQUIP_SLOT_PAD := 3
|
|
const _EQUIP_START_X := 16
|
|
const _EQUIP_START_Y := 228
|
|
const _EQUIP_EMPTY_COL := Color(0.10, 0.10, 0.16)
|
|
const _EQUIP_BORDER_COL := Color(0.30, 0.30, 0.42)
|
|
|
|
# [slot_index][tier] = [fill_color, label_text]
|
|
const _EQUIP_DATA := [
|
|
[ # shield
|
|
[Color(0.10, 0.10, 0.16), ""],
|
|
[Color(0.55, 0.38, 0.18), "Wood\nShield"],
|
|
[Color(0.55, 0.58, 0.62), "Iron\nShield"],
|
|
],
|
|
[ # armor (toughness_tier)
|
|
[Color(0.10, 0.10, 0.16), ""],
|
|
[Color(0.76, 0.47, 0.18), "Leather\nArmor"],
|
|
[Color(0.58, 0.68, 1.00), "Metal\nArmor"],
|
|
],
|
|
[ # boots (kick_tier)
|
|
[Color(0.10, 0.10, 0.16), ""],
|
|
[Color(0.76, 0.47, 0.18), "Leather\nBoots"],
|
|
[Color(0.58, 0.68, 1.00), "Plate\nBoots"],
|
|
[Color(0.75, 0.18, 1.00), "Magic\nBoots"],
|
|
],
|
|
]
|
|
|
|
func _make_equipment_slots() -> void:
|
|
var titles := ["Shield", "Armor", "Boots"]
|
|
for i in range(3):
|
|
var x := _EQUIP_START_X + i * (_EQUIP_SLOT_SIZE + _EQUIP_SLOT_GAP)
|
|
var y := _EQUIP_START_Y
|
|
|
|
var title := _label(Vector2(x, y), titles[i], 16)
|
|
title.size.x = _EQUIP_SLOT_SIZE
|
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
|
|
var border := ColorRect.new()
|
|
border.position = Vector2(x, y + 22)
|
|
border.size = Vector2(_EQUIP_SLOT_SIZE, _EQUIP_SLOT_SIZE)
|
|
border.color = _EQUIP_BORDER_COL
|
|
canvas.add_child(border)
|
|
|
|
var fill := ColorRect.new()
|
|
fill.position = Vector2(x + _EQUIP_SLOT_PAD, y + 22 + _EQUIP_SLOT_PAD)
|
|
fill.size = Vector2(_EQUIP_SLOT_SIZE - _EQUIP_SLOT_PAD * 2, _EQUIP_SLOT_SIZE - _EQUIP_SLOT_PAD * 2)
|
|
fill.color = _EQUIP_EMPTY_COL
|
|
canvas.add_child(fill)
|
|
equip_fills.append(fill)
|
|
|
|
var lbl := Label.new()
|
|
lbl.position = fill.position
|
|
lbl.size = fill.size
|
|
lbl.text = ""
|
|
lbl.add_theme_font_size_override("font_size", 14)
|
|
lbl.add_theme_color_override("font_color", Color.WHITE)
|
|
lbl.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
|
lbl.add_theme_constant_override("shadow_offset_x", 1)
|
|
lbl.add_theme_constant_override("shadow_offset_y", 1)
|
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
canvas.add_child(lbl)
|
|
equip_labels.append(lbl)
|
|
|
|
func _update_equipment_slots() -> void:
|
|
if not is_instance_valid(player):
|
|
return
|
|
var tiers := [
|
|
player.get("shield_tier") as int,
|
|
player.get("toughness_tier") as int,
|
|
player.get("kick_tier") as int,
|
|
]
|
|
for i in range(3):
|
|
var tier: int = tiers[i]
|
|
if tier == _equip_prev_tiers[i]:
|
|
continue
|
|
_equip_prev_tiers[i] = tier
|
|
var data: Array = _EQUIP_DATA[i]
|
|
var entry: Array = data[clampi(tier, 0, data.size() - 1)]
|
|
equip_fills[i].color = entry[0] as Color
|
|
equip_labels[i].text = entry[1] as String
|
|
if tier > 0:
|
|
var tw := create_tween()
|
|
tw.tween_property(equip_fills[i], "color", Color.WHITE, 0.06)
|
|
tw.tween_property(equip_fills[i], "color", entry[0] as Color, 0.22)
|
|
|
|
# ─── 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
|
|
|
|
func _update_tier_label() -> void:
|
|
if not is_instance_valid(player):
|
|
return
|
|
var kt: int = player.get("kick_tier")
|
|
var tt: int = player.get("toughness_tier")
|
|
var st: int = player.get("shield_tier")
|
|
var shield_str := "-" if st == 0 else str(st)
|
|
tier_label.text = "Kick: %d Tough: %d Shield: %s" % [kt, tt, shield_str]
|
|
_update_equipment_slots()
|