362 lines
11 KiB
GDScript
362 lines
11 KiB
GDScript
@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})
|
|
|
|
|
|
|