Files
room_glitch/games/tempest/tempest.gd
T

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})