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 := 10 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 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() # ─── Rocks ──────────────────────────────────────────────────────────────────── var rocks_on_field: int = 0 var rocks_pending: int = 0 func _get_rock_limit() -> int: return mini(2 + (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 := 2 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(20.0).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(20.0).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 = 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_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.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() # ─── 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.1 tutorial_image.anchor_right = 0.9 tutorial_image.anchor_top = 0.07 tutorial_image.anchor_bottom = 0.84 tutorial_image.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED tutorial_image.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL 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.88 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 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) 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) 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() 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)) # Tier display tier_label = _label(Vector2(12, 148), "", 17) 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 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]