Added Glitch Room Source code to Gitty
This commit is contained in:
commit
1ac9eb5644
505 files changed
+28504
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4dgfnl4jboyh
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cy0h6sxqsvped
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user