@tool extends ArcadeGame @export_range(1, 9) var starting_lives: int = 3 ## Number of tubes to clear from the chosen starting level; zero means endless. @export_range(0, 99) var stages_to_win: int = 3 @export_range(0.5, 2.0) var difficulty: float = 1.0 @export_range(0.5, 2.5) var glow_strength: float = 1.35 @export var show_level_select: bool = true const LANES := 16 const START_LEVELS := [1, 3, 5, 7, 9] const START_BONUSES := [0, 6000, 16000, 32000, 54000] var lane: int = 0 var stage: int = 1 var score: int = 0 var high_score: int = 0 var lives: int = 3 var phase: String = "title" var phase_time: float = 0.0 var clock: float = 0.0 var attract_clock: float = 0.0 var selection: int = 0 var cleared: int = 0 var pending_bonus: int = 0 var awarded_bonus: int = 0 var next_extra_life: int = 20000 var enemies: Array[Dictionary] = [] var shots: Array[Dictionary] = [] var sparks: Array[Dictionary] = [] var spikes: Dictionary = {} var remaining: int = 0 var spawn_timer: float = 0.0 var fire_timer: float = 0.0 var move_timer: float = 0.0 var zap_available: bool = true var zap_uses: int = 0 var zap_flash: float = 0.0 var rng := RandomNumberGenerator.new() @onready var view: Node2D = $VectorBoard func _ready() -> void: if not Engine.is_editor_hint(): rng.randomize() lives = starting_lives func start_game() -> bool: if not super.start_game(): return false stage = 1 score = 0 cleared = 0 pending_bonus = 0 awarded_bonus = 0 selection = 0 next_extra_life = 20000 lives = starting_lives lane = 0 enemies.clear() shots.clear() sparks.clear() spikes.clear() zap_flash = 0.0 move_timer = 0.0 phase = "select" if show_level_select else "ready" phase_time = 10.0 if not show_level_select: _begin_stage() return true func confirm_level() -> void: if phase != "select": return stage = START_LEVELS[selection] pending_bonus = START_BONUSES[selection] _begin_stage() func reset_game() -> void: super.reset_game() $VectorAudio.stop() phase = "title" attract_clock = 0.0 enemies.clear() shots.clear() sparks.clear() spikes.clear() stage = 1 score = 0 lives = starting_lives zap_uses = 0 zap_available = true zap_flash = 0.0 pending_bonus = 0 awarded_bonus = 0 cleared = 0 func _begin_stage() -> void: move_timer = 0.0 fire_timer = 0.15 zap_flash = 0.0 enemies.clear() shots.clear() spikes.clear() remaining = 10 + stage * 4 spawn_timer = 0.5 zap_uses = 0 zap_available = true phase = "ready" phase_time = 1.4 func _pressed(action: String) -> bool: return Input.is_action_pressed(action) func _event_action(event: InputEvent, action: String) -> bool: return event.is_action_pressed(action) ## Horizontal trackball travel per lane, measured at the game's logical width. @export_range(1.0, 80.0) var trackball_pixels_per_lane: float = 12.0 var trackball_remainder := 0.0 func _unhandled_input(event: InputEvent) -> void: if Engine.is_editor_hint() or (event is InputEventKey and event.echo): return if event is InputEventMouseMotion: trackball_remainder += event.relative.x * 640.0 / maxf(1.0, get_viewport().get_visible_rect().size.x) var steps := int(trackball_remainder / trackball_pixels_per_lane) if steps != 0: rotate_steps(steps) trackball_remainder -= steps * trackball_pixels_per_lane if _event_action(event, "arcade_start"): if not is_running: start_game() elif phase == "select": confirm_level() elif _event_action(event, "arcade_action_1") and phase == "select": confirm_level() elif _event_action(event, "arcade_action_2"): superzap() if event.is_action_pressed("arcade_rotate_right"): rotate_steps(1) elif event.is_action_pressed("arcade_rotate_left"): rotate_steps(-1) func rotate_steps(steps: int) -> void: if phase == "select": selection = clampi(selection + steps, 0, START_LEVELS.size() - 1) elif phase in ["play", "dive"]: lane = bounded_lane(lane + steps) func is_open_tube() -> bool: return (stage - 1) % 9 == 8 func bounded_lane(value: int) -> int: return clampi(value, 0, LANES - 1) if is_open_tube() else posmod(value, LANES) func superzap() -> void: if phase != "play" or zap_uses >= 2 or zap_flash > 0: return zap_flash = 0.35 $VectorAudio.play("zap") if zap_uses == 0: for enemy in enemies: _destroy(enemy, false) enemies.clear() elif not enemies.is_empty(): var index := rng.randi_range(0, enemies.size() - 1) _destroy(enemies[index], false) enemies.remove_at(index) zap_uses += 1 zap_available = zap_uses < 2 func _physics_process(delta: float) -> void: clock += delta if phase == "title": attract_clock += delta zap_flash = maxf(0.0, zap_flash - delta) for spark in sparks: spark.life -= delta sparks = sparks.filter(func(s: Dictionary) -> bool: return s.life > 0.0) if Engine.is_editor_hint(): view.queue_redraw() return if phase in ["play", "select", "dive"]: move_timer -= delta var direction := int(_pressed("arcade_right")) - int(_pressed("arcade_left")) if direction == 0: move_timer = 0.0 elif move_timer <= 0.0: rotate_steps(direction) move_timer = 0.18 if phase == "select" else 0.075 if phase == "play": fire_timer -= delta if _pressed("arcade_action_1"): fire() _step_play(delta) elif phase in ["select", "ready", "death", "dive", "space", "arrive", "victory"]: phase_time -= delta if phase == "dive" and spikes.get(lane, 0.0) > 0.0 and spikes.get(lane, 0.0) >= dive_depth(): _die() elif phase_time <= 0.0: match phase: "select": confirm_level() "ready": phase = "play" "death": if lives <= 0: phase = "lost" lose_game() else: enemies.clear() shots.clear() spikes.clear() phase = "ready" phase_time = 1.4 "dive": phase = "space" phase_time = 1.2 "space": if stages_to_win > 0 and cleared >= stages_to_win: phase = "victory" phase_time = 1.0 else: stage += 1 phase = "arrive" phase_time = 1.6 "arrive": _begin_stage() "victory": phase = "won" win_game() view.queue_redraw() func dive_depth() -> float: return clampf(phase_time / 1.6, 0.0, 1.0) func fire() -> void: if phase != "play" or fire_timer > 0.0: return shots.append({"lane": lane, "depth": 1.0}) fire_timer = 0.11 $VectorAudio.play("fire") func make_enemy(kind: String, at_lane: int, depth: float = 0.0) -> Dictionary: return {"lane": at_lane, "depth": depth, "kind": kind, "hp": 1, "flip": rng.randf_range(0.7, 1.5), "rim": 0.3, "roll": 0.0, "from_lane": at_lane, "direction": 1, "retreat": false} func _step_play(delta: float) -> void: spawn_timer -= delta if remaining > 0 and spawn_timer <= 0: var kind := "flipper" if stage >= 3 and remaining % 5 == 0: kind = "tanker" elif stage >= 4 and remaining % 4 == 0: kind = "spiker" enemies.append(make_enemy(kind, rng.randi_range(0, LANES - 1))) remaining -= 1 spawn_timer = maxf(0.28, 0.95 - stage * 0.045) / difficulty var newborn: Array[Dictionary] = [] for shot in shots: var old_depth: float = shot.depth shot.depth -= delta * 2.3 var hit: Dictionary = {} for enemy in enemies: if enemy.hp > 0 and collision_lane(enemy) == shot.lane and enemy.depth <= old_depth + 0.055 and enemy.depth >= shot.depth - 0.055: if hit.is_empty() or enemy.depth > hit.depth: hit = enemy var spike_tip: float = spikes.get(int(shot.lane), 0.0) if spike_tip > 0 and old_depth >= spike_tip and shot.depth <= spike_tip and (hit.is_empty() or spike_tip > hit.depth): spikes[int(shot.lane)] = maxf(0.0, spike_tip - 0.10) _add_score(1) shot.depth = -1.0 elif not hit.is_empty(): hit.hp -= 1 shot.depth = -1.0 if hit.hp <= 0: _destroy(hit) if hit.kind == "tanker": for offset in [-1, 1]: newborn.append(make_enemy("flipper", bounded_lane(int(hit.lane) + offset), hit.depth)) shots = shots.filter(func(s: Dictionary) -> bool: return s.depth >= 0.0) enemies = enemies.filter(func(e: Dictionary) -> bool: return e.hp > 0) enemies.append_array(newborn) newborn.clear() for enemy in enemies: enemy.roll = maxf(0.0, float(enemy.get("roll", 0.0)) - delta) var speed := (0.10 + stage * 0.012) * difficulty if enemy.kind == "spiker": if enemy.retreat: enemy.depth -= delta * speed * 1.8 if enemy.depth <= 0: enemy.hp = 0 else: enemy.depth += delta * speed spikes[int(enemy.lane)] = maxf(spikes.get(int(enemy.lane), 0.0), enemy.depth) if enemy.depth > 0.65: enemy.retreat = true continue enemy.depth = minf(1.0, enemy.depth + delta * speed) if enemy.kind == "tanker" and enemy.depth > 0.75: enemy.hp = 0 for offset in [-1, 1]: newborn.append(make_enemy("flipper", bounded_lane(int(enemy.lane) + offset), enemy.depth)) continue if enemy.depth >= 1.0: enemy.rim -= delta if enemy.rim <= 0 and enemy.roll <= 0: var difference := lane - int(enemy.lane) if is_open_tube() else posmod(lane - int(enemy.lane), LANES) if difference != 0: _start_roll(enemy, (1 if difference > 0 else -1) if is_open_tube() else (1 if difference <= LANES / 2 else -1)) enemy.rim = 0.32 if collision_lane(enemy) == lane: _die() return elif enemy.kind == "flipper" and enemy.depth > 0.28 and stage >= 2: enemy.flip -= delta if enemy.flip <= 0 and enemy.roll <= 0: _start_roll(enemy, 1 if rng.randf() > 0.5 else -1) enemy.flip = maxf(0.4, 1.4 - stage * 0.05) enemies.append_array(newborn) enemies = enemies.filter(func(e: Dictionary) -> bool: return e.hp > 0) if remaining == 0 and enemies.is_empty(): cleared += 1 awarded_bonus = pending_bonus _add_score(pending_bonus) pending_bonus = 0 phase = "dive" phase_time = 1.6 $VectorAudio.play("level") shots.clear() func _start_roll(enemy: Dictionary, direction: int) -> void: enemy.from_lane = enemy.lane enemy.direction = direction if is_open_tube() and (int(enemy.lane) + direction < 0 or int(enemy.lane) + direction >= LANES): return enemy.lane = bounded_lane(int(enemy.lane) + direction) enemy.roll = 0.24 func collision_lane(enemy: Dictionary) -> int: return int(enemy.get("from_lane", enemy.lane)) if float(enemy.get("roll", 0.0)) > 0.12 else int(enemy.lane) func _die() -> void: lives -= 1 $VectorAudio.play("death") sparks.append({"lane": lane, "depth": 1.0, "life": 0.7}) if phase == "dive": # A failed warp resumes the cleared tube without counting it twice. cleared -= 1 phase = "death" phase_time = 1.2 func _add_score(points: int) -> void: score += points high_score = maxi(high_score, score) while score >= next_extra_life: lives += 1 next_extra_life += 20000 func _destroy(enemy: Dictionary, _split: bool = true) -> void: if zap_flash <= 0.0: $VectorAudio.play("hit") _add_score({"tanker": 100, "spiker": 50}.get(enemy.kind, 150)) sparks.append({"lane": enemy.lane, "depth": enemy.depth, "life": 0.45})