Added Glitch Room Source code to Gitty

This commit is contained in:
Jeff Luke committed 2026-10-04 11:31:17 -07:00
commit 1ac9eb5644
505 files changed
+28504

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
# Space Invaders setup and fidelity notes
Space Invaders is registered in the project database and uses the existing ArcadeGame start, win, loss, and reset events. Open `SpaceInvaders.tscn` and run the current scene to play, or select the Space Invaders game definition in a cabinet scenario.
## Controls and room configuration
- Enter or `arcade_start`: start a fresh game, including during the attract/demo loop.
- Left/right arrows or `arcade_left`/`arcade_right`: move the laser base.
- Space or `arcade_action_1`: fire. Holding fire repeats when the previous shot finishes; only one player shot can exist at once.
- Cabinet capabilities: `joystick_horizontal`, `button_1`, and `start_button`.
Select the scene root to configure Waves To Win (default 1; 0 means endless), Starting Lives (3), Bonus Life Score (1500; 0 disables), Color Overlay, and Sound Enabled. These are scene exports, not run_config overrides. Save the scene after changing them. Existing cabinet scenario assignments were not changed.
Winning the configured number of waves emits game_won. Invasion or loss of the last base emits game_lost. The loss screen returns to attract after five seconds; the room win screen stays visible. A reset returns to attract while keeping the session high score.
## Included behavior
The game renders at 224 by 256 pixels, with nearest-neighbor scaling and integer enlargement whenever the available space permits. A screen-space overlay colors the upper band red and lower band green; sprites crossing a band change color at the band boundary. Black margins contain off-screen objects. Disable Color Overlay for monochrome.
There are 55 invaders in five rows. A scan moves one living invader per simulation tick, producing a ripple through the formation and a faster sweep as the population falls. Invaders alternate the two supplied poses on movement, reverse and descend at an edge, and destroy shield sections they overlap. Successive waves start lower. The last invader has a larger horizontal step.
Player shots, enemy shots, and invaders erode four shields. Shield holes affect collisions immediately. Projectiles advance in one-pixel collision substeps so they cannot skip thin remaining shield sections. Enemy fire comes from the lowest surviving invader in a column, with three separately animated shot types; one type aims its launch column toward the player. Opposing projectiles can collide.
The saucer crosses above the fleet, has a shot-count-based score, and displays its award when destroyed. The 15-entry score cycle includes 50, 100, 150, and 300. Invaders award 10, 20, or 30 by type. A single extra life is awarded at the configured score threshold.
Attract mode types the title and score table, includes the inverted-Y repair animation, shows a free-play start screen, then runs a silent gameplay demo. The demo uses the live game simulation but never starts a room run, changes the high score, or emits win/loss events. Start interrupts it and resets the board.
## Supplied sound mapping
| File | Use |
| --- | --- |
| shoot.wav | Player shot |
| invaderkilled.wav | Invader destroyed |
| explosion.wav | Player base destroyed |
| fastinvader1.wav through fastinvader4.wav | Repeating four-note fleet march |
| ufo_lowpitch.wav | Repeating saucer flight sound |
| ufo_highpitch.wav | Saucer hit |
Audio is silent during attract/demo and stops on reset or game completion. The source sprite sheet and WAV files are copied unchanged from the supplied assets. White pixels are extracted into transparent sprite masks at runtime; those same masks are used for sprite hit detection.
## Fidelity boundaries
This is a recreation, not ROM emulation. The current cabinet flow is one-player free play; SCORE<2> and CREDIT 00 are visual elements, with no coin accounting or alternating two-player game. There is no persistent high-score storage. Attract choreography and delays are approximated from the reference, and the extra attract skits are not all reproduced. Enemy reload timing and column selection approximate the original rather than reproduce every firing table and hardware interrupt quirk. Shield crater patterns are deterministic approximations. Ship movement, projectile speeds, the final-alien behavior, and sound cadence still benefit from side-by-side arcade tuning.
Reference visuals: [supplied gameplay video](https://www.youtube.com/watch?v=S-CLMRQvItE). The first segment is Space Invaders; the video also contains other games. Fleet processing, alien scores, saucer scoring, and subsequent wave heights were informed by the [annotated original program](https://computerarcheology.com/Arcade/SpaceInvaders/Code.html) and [technical analysis](https://computerarcheology.com/Arcade/SpaceInvaders/).
## Validation and play checklist
Automated checks cover fleet count/ripple/descent, shot limits, shield holes, alien and saucer hits, projectile interception, player death/respawn, invasion, bonus life, wave progression, start/reset, demo event and score isolation, and database registration. They pass with Godot 4.6.1 Compatibility. The project remains configured for 4.7; test in that version before deployment. Desktop title and gameplay renders were inspected. A short uncapped desktop render benchmark completed without a game script error; it is not a Raspberry Pi performance guarantee. The sandbox reports certificate-store and shader-cache access errors during desktop validation.
Run `tests/space_invaders/check_space_invaders.gd` with Godot's `--headless --path . --script` options. `render_preview.gd` generates title/gameplay captures, and `benchmark.gd` runs a short desktop render check.
- Start during both the title animation and demo; confirm a fresh score, lives, and shields.
- Fire through a shield repeatedly; verify the shot eventually passes through the hole.
- Watch the fleet accelerate and descend as columns disappear.
- Check the four march sounds, firing, deaths, and both saucer sounds at cabinet volume.
- Complete a wave and verify the room event; set Waves To Win to 0 to check continued play.
- Lose the last life or allow invasion; confirm the room loss event and return to attract.
- Resize the game view and inspect equal pixel sizes, overlay bands, and black margins.
+31
View File
@@ -0,0 +1,31 @@
[gd_scene format=3 uid="uid://75kpoxbn0gv7"]
[ext_resource type="Script" uid="uid://bs0afkx0eduyh" path="res://games/space_invaders/space_invaders.gd" id="1"]
[ext_resource type="Script" uid="uid://b4dgfnl4jboyh" path="res://games/space_invaders/board.gd" id="2"]
[ext_resource type="Script" uid="uid://cy0h6sxqsvped" path="res://games/space_invaders/display.gd" id="3"]
[ext_resource type="Script" uid="uid://b4xajrp7m3nv5" path="res://games/space_invaders/audio.gd" id="4"]
[ext_resource type="Shader" uid="uid://iw11px3gsupb" path="res://games/space_invaders/overlay.gdshader" id="5"]
[sub_resource type="ShaderMaterial" id="Overlay"]
shader = ExtResource("5")
shader_parameter/overlay = true
[node name="SpaceInvaders" type="Node" unique_id=469852513]
script = ExtResource("1")
[node name="PixelScreen" type="SubViewport" parent="." unique_id=1436969421]
disable_3d = true
size = Vector2i(224, 256)
render_target_update_mode = 4
[node name="Board" type="Node2D" parent="PixelScreen" unique_id=2075363350]
texture_filter = 1
script = ExtResource("2")
[node name="Display" type="Node2D" parent="." unique_id=675158066]
texture_filter = 1
material = SubResource("Overlay")
script = ExtResource("3")
[node name="Audio" type="Node" parent="." unique_id=1599851911]
script = ExtResource("4")
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b0n0f2eqrqpuh"
path="res://.godot/imported/sprites.png-5aa86391f2c5b6dcb3cfc96492778056.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://games/space_invaders/art/sprites.png"
dest_files=["res://.godot/imported/sprites.png-5aa86391f2c5b6dcb3cfc96492778056.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+33
View File
@@ -0,0 +1,33 @@
extends Node
const FILES := {"shot":"shoot", "invader":"invaderkilled", "death":"explosion", "march0":"fastinvader1", "march1":"fastinvader2", "march2":"fastinvader3", "march3":"fastinvader4", "ufo_hit":"ufo_highpitch"}
var tracks: Dictionary = {}
var voices: Array[AudioStreamPlayer] = []
var next_voice := 0
var saucer: AudioStreamPlayer
func _ready() -> void:
for key in FILES: tracks[key] = load("res://games/space_invaders/audio/%s.wav"%FILES[key])
for i in range(8):
var voice := AudioStreamPlayer.new()
voice.volume_db = -12
add_child(voice)
voices.append(voice)
saucer = AudioStreamPlayer.new()
saucer.stream = load("res://games/space_invaders/audio/ufo_lowpitch.wav")
saucer.volume_db = -16
add_child(saucer)
saucer.finished.connect(func():
if get_parent().saucer_active and not get_parent().demo and get_parent().phase=="play": saucer.play())
func effect(key: String) -> void:
if get_parent().demo or not get_parent().sound_enabled or not tracks.has(key): return
var voice := voices[next_voice]
next_voice = (next_voice+1)%voices.size()
voice.stream = tracks[key]
voice.play()
func ufo(active: bool) -> void:
if not active:
saucer.stop()
elif not get_parent().demo and get_parent().sound_enabled:
saucer.play()
func stop_all() -> void:
for voice in voices: voice.stop()
if saucer: saucer.stop()
+1
View File
@@ -0,0 +1 @@
uid://b4xajrp7m3nv5
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://bwe5etevdqklk"
path="res://.godot/imported/explosion.wav-9901c75dd3de8a2026fa8bde27440b0f.sample"
[deps]
source_file="res://games/space_invaders/audio/explosion.wav"
dest_files=["res://.godot/imported/explosion.wav-9901c75dd3de8a2026fa8bde27440b0f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dbl1isaefpkho"
path="res://.godot/imported/fastinvader1.wav-c01935dea3c9461078e7be2197824cfe.sample"
[deps]
source_file="res://games/space_invaders/audio/fastinvader1.wav"
dest_files=["res://.godot/imported/fastinvader1.wav-c01935dea3c9461078e7be2197824cfe.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c6naet4vmkv4l"
path="res://.godot/imported/fastinvader2.wav-50e6d71fcb4f0882c996ce114cf1d72f.sample"
[deps]
source_file="res://games/space_invaders/audio/fastinvader2.wav"
dest_files=["res://.godot/imported/fastinvader2.wav-50e6d71fcb4f0882c996ce114cf1d72f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cqpgb2oqqksu4"
path="res://.godot/imported/fastinvader3.wav-8701d0aaf6df70ac55ba0c292c858bf0.sample"
[deps]
source_file="res://games/space_invaders/audio/fastinvader3.wav"
dest_files=["res://.godot/imported/fastinvader3.wav-8701d0aaf6df70ac55ba0c292c858bf0.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cs2e7tb4ss60d"
path="res://.godot/imported/fastinvader4.wav-461a7445c1780502161b5194db9b44a6.sample"
[deps]
source_file="res://games/space_invaders/audio/fastinvader4.wav"
dest_files=["res://.godot/imported/fastinvader4.wav-461a7445c1780502161b5194db9b44a6.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://chhb7wtwfmpdq"
path="res://.godot/imported/invaderkilled.wav-a2b9ae3e15ff35186379d60f53760a98.sample"
[deps]
source_file="res://games/space_invaders/audio/invaderkilled.wav"
dest_files=["res://.godot/imported/invaderkilled.wav-a2b9ae3e15ff35186379d60f53760a98.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b34upsqqnv23f"
path="res://.godot/imported/shoot.wav-04a9da97b02f69f32dc83caa94bacc5a.sample"
[deps]
source_file="res://games/space_invaders/audio/shoot.wav"
dest_files=["res://.godot/imported/shoot.wav-04a9da97b02f69f32dc83caa94bacc5a.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cy2fqvuyhox7q"
path="res://.godot/imported/ufo_highpitch.wav-a3fa17d7a70964b5de281fbe549b376f.sample"
[deps]
source_file="res://games/space_invaders/audio/ufo_highpitch.wav"
dest_files=["res://.godot/imported/ufo_highpitch.wav-a3fa17d7a70964b5de281fbe549b376f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cru6woa2ca1fs"
path="res://.godot/imported/ufo_lowpitch.wav-1b827f7d56eb25dfcfd6ea3342e26e7e.sample"
[deps]
source_file="res://games/space_invaders/audio/ufo_lowpitch.wav"
dest_files=["res://.godot/imported/ufo_lowpitch.wav-1b827f7d56eb25dfcfd6ea3342e26e7e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+119
View File
@@ -0,0 +1,119 @@
@tool
extends Node2D
const SHEET = preload("res://games/space_invaders/art/sprites.png")
const GLYPHS := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*?-"
var masks: Dictionary = {}
var textures: Dictionary = {}
var game: Node
func _ready() -> void:
game = get_parent().get_parent()
var sheet := SHEET.get_image()
for kind in range(3):
for frame in range(2): register("alien%d_%d"%[kind,frame],sheet,Rect2i(1+kind*18,1+frame*10,16,8))
register("burst",sheet,Rect2i(55,1,16,8))
register("ufo",sheet,Rect2i(1,39,16,8))
register("ufo_burst",sheet,Rect2i(19,39,24,8))
register("shield",sheet,Rect2i(45,31,24,16))
register("player",sheet,Rect2i(1,49,16,8))
register("death0",sheet,Rect2i(19,49,16,8))
register("death1",sheet,Rect2i(37,49,16,8))
register("shot_burst",sheet,Rect2i(55,49,10,8))
for i in range(12): register("bomb%d"%i,sheet,Rect2i(1+i*5,21,3,8))
for i in range(GLYPHS.length()): register(GLYPHS[i],sheet,Rect2i(1+(i%8)*10,69+int(i/8)*10,8,8))
func register(key: String, sheet: Image, region: Rect2i) -> void:
var pixels := sheet.get_region(region)
pixels.convert(Image.FORMAT_RGBA8)
for y in range(pixels.get_height()):
for x in range(pixels.get_width()):
var value := pixels.get_pixel(x,y)
pixels.set_pixel(x,y,Color.WHITE if value.r>.8 and value.g>.8 and value.b>.8 else Color.TRANSPARENT)
masks[key] = pixels
textures[key] = ImageTexture.create_from_image(pixels)
func sprite(key: String, point: Vector2) -> void:
draw_texture(textures[key],point.round())
func text(value: String, point: Vector2, revealed: int = 999) -> void:
for i in range(mini(value.length(),revealed)):
if textures.has(value[i]): sprite(value[i],point+Vector2(i*8,0))
func centered(value: String, y: float, revealed: int = 999) -> void:
text(value,Vector2((224-value.length()*8)/2.0,y),revealed)
func title_repair(t: float) -> void:
var letter_x := 120.0
var invader_x := 232.0
# Carry the letter beside the invader, fully beyond the right edge,
# then bring it back upright before leaving it in its original position.
if t < 12:
invader_x = lerpf(232,128,clampf((t-9)/3,0,1))
elif t < 15:
invader_x = lerpf(128,232,(t-12)/3)
letter_x = invader_x-8
elif t < 15.5:
letter_x = 224
elif t < 18.5:
invader_x = lerpf(232,128,(t-15.5)/3)
letter_x = invader_x-8
else:
invader_x = lerpf(128,232,clampf((t-18.5)/3,0,1))
if t > .5:
if t < 15.5:
draw_set_transform(Vector2(roundf(letter_x),72),0,Vector2(1,-1))
sprite("Y",Vector2.ZERO)
draw_set_transform(Vector2.ZERO)
else:
sprite("Y",Vector2(letter_x,64))
if t >= 9 and t < 21.5:
sprite("alien0_%d"%(int(t*5)%2),Vector2(invader_x,64))
func attract() -> void:
var t: float = game.phase_time
if t<22:
# The little invader repairs the upside-down Y in PLAY.
text("PLA",Vector2(96,64),int(t*8))
title_repair(t)
centered("SPACE INVADERS",88,int((t-1)*12))
if t>=2.5: centered("*SCORE ADVANCE TABLE*",120,int((t-2.5)*16))
for i in range(4):
if t<4+i*.6: continue
sprite(["ufo","alien0_0","alien1_0","alien2_0"][i],Vector2(56,136+i*16))
text(["=? MYSTERY","=30 POINTS","=20 POINTS","=10 POINTS"][i],Vector2(72,136+i*16),int((t-4-i*.6)*15))
else:
centered("PUSH START",104)
centered("<1 PLAYER>",136)
centered("*FREE PLAY*",160)
centered("BONUS AT %04d"%game.bonus_life_score,192)
func _draw() -> void:
if game == null: return
draw_rect(Rect2(0,0,224,256),Color.BLACK)
text("SCORE<1> HI-SCORE SCORE<2>",Vector2(8,8))
text("%04d"%(game.last_score if game.demo else game.score),Vector2(24,24))
text("%04d"%game.high_score,Vector2(88,24))
text("0000",Vector2(176,24))
text("CREDIT 00",Vector2(144,248))
if game.phase == "attract":
attract()
return
for alien in game.aliens:
if alien.alive: sprite("alien%d_%d"%[alien.kind,alien.frame],alien.position-Vector2(8,4))
for shield in game.shields: draw_texture(shield.texture,Vector2(shield.position))
if game.saucer_active: sprite("ufo",Vector2(game.saucer_x-8,40))
if game.phase == "dead": sprite("death%d"%(int(game.phase_time*10)%2),Vector2(game.player_x-8,216))
elif game.phase in ["play","clear"]: sprite("player",Vector2(game.player_x-8,216))
if game.shot_active: draw_rect(Rect2(game.player_shot.round()-Vector2(0,1),Vector2(1,4)),Color.WHITE)
for bomb in game.bombs:
sprite("bomb%d"%(bomb.kind*4+int(game.clock*12)%4),bomb.position-Vector2(1,4))
for effect in game.effects:
if effect.text != "" and effect.time<.4: text(effect.text,effect.position-Vector2(12,4))
else: sprite(effect.name,effect.position-Vector2(8,4))
draw_line(Vector2(8,239),Vector2(216,239),Color.WHITE,1)
text(str(game.lives),Vector2(8,248))
for i in range(mini(maxi(0,game.lives-1),6)): sprite("player",Vector2(24+i*16,248))
if game.phase == "ready": centered("PLAY PLAYER<1>",160)
if game.phase == "won": centered("YOU WIN",160)
if game.phase == "lost" or (game.demo and int(game.demo_time)%2==0): centered("GAME OVER",160)
+1
View File
@@ -0,0 +1 @@
uid://b4dgfnl4jboyh
+13
View File
@@ -0,0 +1,13 @@
@tool
extends Node2D
func _process(_delta: float) -> void:
material.set_shader_parameter("overlay",get_parent().color_overlay)
queue_redraw()
func _draw() -> void:
var available := get_viewport_rect().size if not Engine.is_editor_hint() else Vector2(672,768)
var scale_factor := minf(available.x/224,available.y/256)
# Whole-number enlargement preserves equal pixel widths whenever it fits.
if scale_factor>=1: scale_factor = floorf(scale_factor)
var size := Vector2(224,256)*scale_factor
draw_rect(Rect2(Vector2.ZERO,available),Color.BLACK)
draw_texture_rect($"../PixelScreen".get_texture(),Rect2(((available-size)*.5).floor(),size),false)
+1
View File
@@ -0,0 +1 @@
uid://cy0h6sxqsvped
+13
View File
@@ -0,0 +1,13 @@
shader_type canvas_item;
render_mode unshaded;
uniform bool overlay = true;
void fragment() {
vec4 pixel = texture(TEXTURE,UV) * COLOR;
vec2 p = UV * vec2(224.0,256.0);
vec3 tint = vec3(1.0);
if (overlay) {
if (p.y>=32.0 && p.y<64.0) tint=vec3(1.0,0.05,0.03);
if ((p.y>=184.0 && p.y<240.0) || (p.y>=240.0 && p.x>=24.0 && p.x<136.0)) tint=vec3(0.1,1.0,0.05);
}
COLOR=vec4(pixel.rgb*tint,pixel.a);
}
@@ -0,0 +1 @@
uid://iw11px3gsupb
+402
View File
@@ -0,0 +1,402 @@
@tool
extends ArcadeGame
@export_range(0, 99) var waves_to_win: int = 1
@export_range(1, 6) var starting_lives: int = 3
@export_range(0, 10000, 100) var bonus_life_score: int = 1500
@export var color_overlay: bool = true
@export var sound_enabled: bool = true
const SAUCER_SCORES := [100,50,50,100,150,100,100,50,300,100,100,100,50,150,100]
const WAVE_HEIGHTS := [96,104,112,112,112,120,120,120]
var phase := "attract"
var phase_time := 0.0
var clock := 0.0
var demo := false
var demo_time := 0.0
var score := 0
var high_score := 0
var last_score := 0
var lives := 3
var wave := 1
var extra_awarded := false
var player_x := 24.0
var player_shot := Vector2(-100,-100)
var shot_active := false
var shot_count := 0
var shot_delay := 0.0
var aliens: Array[Dictionary] = []
var bombs: Array[Dictionary] = []
var effects: Array[Dictionary] = []
var shields: Array[Dictionary] = []
var fleet_direction := 1
var fleet_cursor := 0
var fleet_delta := Vector2(2,0)
var fleet_ticks := 0.0
var march_note := 0
var march_sound_delay := 0.0
var hit_pause := 0.0
var bomb_timer := 1.0
var bomb_kind := 0
var bomb_column := 0
var saucer_x := -32.0
var saucer_direction := 1
var saucer_active := false
var saucer_timer := 20.0
var demo_target := 112.0
@onready var board: Node2D = $PixelScreen/Board
func _ready() -> void:
new_wave()
lives = starting_lives
func new_wave() -> void:
aliens.clear()
var top: int = 72 if wave == 1 else WAVE_HEIGHTS[(wave-2)%WAVE_HEIGHTS.size()]
# Bottom-to-top scan order gives the original rippling fleet movement.
for row in range(4,-1,-1):
for column in range(11):
aliens.append({"position":Vector2(24+column*16,top+row*16),"kind":0 if row==0 else (1 if row<3 else 2),"frame":0,"alive":true,"column":column})
fleet_direction = 1
fleet_cursor = 0
fleet_ticks = 0
march_sound_delay = 0
march_note = 0
fleet_delta = Vector2(2,0)
bombs.clear()
effects.clear()
shot_active = false
shot_delay = 0
bomb_timer = 1
saucer_active = false
saucer_timer = 20
hit_pause = 0
player_x = 24
build_shields()
func build_shields() -> void:
shields.clear()
var pattern: Image = board.masks["shield"]
for x in [32,77,122,167]:
var pixels := pattern.duplicate() as Image
shields.append({"position":Vector2i(x,192),"image":pixels,"texture":ImageTexture.create_from_image(pixels),"dirty":false})
func start_game() -> bool:
if is_running: return false
demo = false
$Audio.stop_all()
score = 0
lives = starting_lives
wave = 1
shot_count = 0
extra_awarded = false
clock = 0
new_wave()
phase = "ready"
phase_time = 2
return super.start_game()
func reset_game() -> void:
demo = false
$Audio.stop_all()
score = 0
last_score = 0
wave = 1
lives = starting_lives
new_wave()
phase = "attract"
phase_time = 0
clock = 0
super.reset_game()
func begin_demo() -> void:
demo = true
demo_time = 0
score = 0
lives = starting_lives
wave = 1
shot_count = 0
new_wave()
phase = "play"
func end_demo() -> void:
demo = false
score = last_score
phase = "attract"
phase_time = 0
$Audio.stop_all()
func _unhandled_input(event: InputEvent) -> void:
if Engine.is_editor_hint() or event.is_echo(): return
if event.is_action_pressed("arcade_start"):
start_game()
func held(action: StringName) -> bool:
return Input.is_action_pressed(action)
func _physics_process(delta: float) -> void:
clock += delta
if Engine.is_editor_hint():
phase_time = fmod(clock,22)
board.queue_redraw()
return
if phase == "attract":
phase_time += delta
if phase_time >= 28: begin_demo()
elif phase == "ready":
phase_time -= delta
if phase_time <= 0:
phase = "play"
if saucer_active: $Audio.ufo(true)
elif phase == "lost":
phase_time -= delta
if phase_time <= 0:
phase = "attract"
phase_time = 0
elif phase == "dead":
phase_time -= delta
if phase_time <= 0:
if lives <= 0: finish(false)
else:
player_x = 24
bombs.clear()
shot_active = false
phase = "ready"
phase_time = 1.0
elif phase == "clear":
phase_time -= delta
if phase_time <= 0:
if demo: end_demo()
elif waves_to_win > 0 and wave >= waves_to_win: finish(true)
else:
wave += 1
new_wave()
phase = "ready"
phase_time = 1.5
elif phase == "play":
var move := 0.0
if demo:
demo_target = 112+sin(demo_time*.7)*88
move = signf(demo_target-player_x) if absf(demo_target-player_x)>1 else 0.0
else:
move = float(held(&"arcade_right"))-float(held(&"arcade_left"))
player_x = clampf(player_x+move*60*delta,16,208)
shot_delay = maxf(0,shot_delay-delta)
if demo or held(&"arcade_action_1"): fire()
step_world(delta)
if demo:
demo_time += delta
if demo_time >= 25: end_demo()
for shield in shields:
if shield.dirty:
shield.texture.update(shield.image)
shield.dirty = false
board.queue_redraw()
func fire() -> bool:
if phase != "play" or shot_active or shot_delay > 0: return false
shot_active = true
player_shot = Vector2(roundf(player_x),216)
$Audio.effect("shot")
return true
func remove_shot() -> void:
if not shot_active: return
shot_active = false
shot_count += 1
shot_delay = .08
func alive_count() -> int:
var count := 0
for alien in aliens:
if alien.alive: count += 1
return count
func march() -> void:
var count := alive_count()
if count == 0: return
if fleet_cursor == 0:
var edge := false
for alien in aliens:
if alien.alive and (alien.position.x+fleet_direction*2 > 208 or alien.position.x+fleet_direction*2 < 16): edge = true
if edge:
fleet_direction *= -1
fleet_delta = Vector2(0,8)
else: fleet_delta = Vector2(fleet_direction*(3 if count==1 else 2),0)
if march_sound_delay <= 0:
$Audio.effect("march%d"%march_note)
march_note = (march_note+1)%4
march_sound_delay = .08
while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1
if fleet_cursor < aliens.size():
var alien: Dictionary = aliens[fleet_cursor]
alien.position += fleet_delta
alien.frame = 1-alien.frame
var bounds := Rect2(alien.position-Vector2(8,4),Vector2(16,8))
for shield in shields:
if bounds.intersects(Rect2(Vector2(shield.position),Vector2(shield.image.get_size()))):
for y in range(shield.image.get_height()):
for x in range(shield.image.get_width()):
if bounds.has_point(Vector2(shield.position+Vector2i(x,y))): shield.image.set_pixel(x,y,Color.TRANSPARENT)
shield.dirty = true
if alien.position.y >= 216: finish(false)
fleet_cursor += 1
while fleet_cursor < aliens.size() and not aliens[fleet_cursor].alive: fleet_cursor += 1
if fleet_cursor >= aliens.size(): fleet_cursor = 0
func launch_bomb() -> void:
var kind := bomb_kind
bomb_kind = (bomb_kind+1)%3
for bomb in bombs:
if bomb.kind == kind: return
if kind == 1 and alive_count() == 1: return
var candidates: Array[Dictionary] = []
var columns: Dictionary = {}
for alien in aliens:
if alien.alive and not columns.has(alien.column):
columns[alien.column] = true
candidates.append(alien)
if candidates.is_empty(): return
var source: Dictionary = candidates[bomb_column%candidates.size()]
bomb_column += 3
if kind == 0:
for alien in candidates:
if absf(alien.position.x-player_x) < absf(source.position.x-player_x): source = alien
bombs.append({"position":source.position+Vector2(0,8),"kind":kind})
func pixel_hit(name: String, origin: Vector2, point: Vector2) -> bool:
var image: Image = board.masks[name]
var p := Vector2i(point.round()-origin.round())
return p.x>=0 and p.y>=0 and p.x<image.get_width() and p.y<image.get_height() and image.get_pixelv(p).a>.5
func shield_hit(point: Vector2, upward: bool) -> bool:
for shield in shields:
var p := Vector2i(point.round())-Vector2i(shield.position)
var image: Image = shield.image
if p.x<0 or p.y<0 or p.x>=image.get_width() or p.y>=image.get_height(): continue
if image.get_pixelv(p).a < .5: continue
for dy in range(-4,5):
for dx in range(-3,4):
var q := p+Vector2i(dx,dy)
if q.x<0 or q.y<0 or q.x>=image.get_width() or q.y>=image.get_height(): continue
var depth := -dy if upward else dy
if dx*dx+dy*dy<=12 and depth>=-1 and (absi(dx)<2 or (q.x+q.y)%3!=0): image.set_pixelv(q,Color.TRANSPARENT)
shield.dirty = true
return true
return false
func blast(position: Vector2, name: String = "burst", duration: float = .16, text: String = "") -> void:
effects.append({"position":position,"name":name,"time":duration,"text":text})
func step_world(delta: float) -> void:
march_sound_delay = maxf(0,march_sound_delay-delta)
for i in range(effects.size()-1,-1,-1):
effects[i].time -= delta
if effects[i].time <= 0: effects.remove_at(i)
hit_pause = maxf(0,hit_pause-delta)
if hit_pause == 0:
fleet_ticks += delta*60
while fleet_ticks >= 1 and phase == "play":
fleet_ticks -= 1
march()
if phase != "play": return
bomb_timer -= delta
if bomb_timer <= 0:
launch_bomb()
bomb_timer = maxf(.25,.75-score/20000.0)
saucer_timer -= delta
if saucer_timer <= 0 and not saucer_active:
saucer_active = true
saucer_direction = 1 if shot_count%2==0 else -1
saucer_x = -12 if saucer_direction==1 else 236
$Audio.ufo(true)
if saucer_active:
saucer_x += saucer_direction*30*delta
if saucer_x < -16 or saucer_x > 240:
saucer_active = false
saucer_timer = 23
$Audio.ufo(false)
# One-pixel substeps prevent fast projectiles tunneling through shields/sprites.
if shot_active:
var travel := delta*240
while travel > 0 and shot_active:
var amount := minf(1,travel)
player_shot.y -= amount
travel -= amount
if shield_hit(player_shot,true): remove_shot()
elif player_shot.y <= 32:
blast(player_shot,"shot_burst")
remove_shot()
elif saucer_active and pixel_hit("ufo",Vector2(saucer_x-8,40),player_shot):
var value: int = SAUCER_SCORES[shot_count%15]
add_score(value)
blast(Vector2(saucer_x,44),"ufo_burst",.65,str(value))
saucer_active = false
saucer_timer = 23
$Audio.ufo(false)
$Audio.effect("ufo_hit")
remove_shot()
else:
for alien in aliens:
if alien.alive and pixel_hit("alien%d_%d"%[alien.kind,alien.frame],alien.position-Vector2(8,4),player_shot):
alien.alive = false
add_score([30,20,10][alien.kind])
blast(alien.position)
hit_pause = .10
$Audio.effect("invader")
remove_shot()
break
for i in range(bombs.size()-1,-1,-1):
var bomb: Dictionary = bombs[i]
var destroyed := false
var travel := delta*(90 if alive_count()<9 else 60)
while travel>0 and not destroyed:
var amount := minf(1,travel)
bomb.position.y += amount
travel -= amount
if shield_hit(bomb.position+Vector2(0,3),false): destroyed = true
elif shot_active and absf(bomb.position.x-player_shot.x)<3 and absf(bomb.position.y-player_shot.y)<7:
destroyed = true
remove_shot()
blast(bomb.position,"shot_burst")
elif pixel_hit("player",Vector2(roundf(player_x)-8,216),bomb.position+Vector2(0,3)):
die()
destroyed = true
elif bomb.position.y>=238:
destroyed = true
blast(Vector2(bomb.position.x,237),"shot_burst")
if destroyed: bombs.remove_at(i)
if phase != "play": return
if alive_count()==0:
phase = "clear"
phase_time = 1.5
$Audio.stop_all()
func add_score(value: int) -> void:
score += value
if demo: return
high_score = maxi(high_score,score)
if bonus_life_score>0 and score>=bonus_life_score and not extra_awarded:
extra_awarded = true
lives += 1
$Audio.effect("bonus")
func die() -> void:
if phase != "play": return
lives -= 1
phase = "dead"
phase_time = 1.5
$Audio.stop_all()
$Audio.effect("death")
func finish(won: bool) -> void:
if demo:
end_demo()
return
if not is_running: return
last_score = score
phase = "won" if won else "lost"
phase_time = 5
$Audio.stop_all()
if won: win_game()
else: lose_game()
@@ -0,0 +1 @@
uid://bs0afkx0eduyh