Add spatial audio, spacecraft entity, and level intro sequence

Distance-based sound effects with stereo panning for explosions, impacts,
and pickups. Spacecraft entity with full state machine (fly-in, land, take
off, fly-out), engine/synth sound loops, thruster particles, and PNG
spritesheet. Moon level intro defers player spawn until ship lands.

Also untrack build/ objects that were committed by mistake.
This commit is contained in:
Thomas
2026-03-01 11:00:51 +00:00
parent dbb507bfd2
commit 49ed2d6f7b
33 changed files with 1026 additions and 61 deletions

360
tools/gen_spacecraft.py Normal file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Generate spacecraft spritesheet PNG for the JNR engine.
Output: assets/sprites/spacecraft.png
Layout: 5 frames horizontally, each 80x48 pixels = 400x48 total
Frames: landed, landing1, landing2, landing3, landing4
The ship faces RIGHT, uses the game's pixel art color palette.
"""
from PIL import Image
# ── Color palette (RGBA) ─────────────────────────────
T = (0, 0, 0, 0) # transparent
BLK = (26, 26, 46, 255) # dark/outline
WHT = (238, 238, 234, 255) # white
GRY = (136, 136, 136, 255) # grey
GYD = (85, 85, 85, 255) # grey dark
GYL = (187, 187, 187, 255) # grey light
BLU = (74, 124, 189, 255) # blue accent
BLD = (54, 94, 143, 255) # blue dark
BLL = (111, 168, 220, 255) # blue light
CYN = (77, 208, 225, 255) # cyan cockpit
CYD = (0, 172, 193, 255) # cyan dark
ORG = (255, 152, 0, 255) # orange
ORD = (204, 122, 0, 255) # orange dark
YLW = (255, 213, 79, 255) # yellow
RED = (217, 68, 68, 255) # red
W, H = 80, 48 # frame size
def make_frame():
"""Return a blank 80x48 pixel grid."""
return [[T] * W for _ in range(H)]
def hline(grid, y, x0, x1, color):
"""Draw horizontal line from x0 to x1 inclusive."""
if y < 0 or y >= H:
return
for x in range(max(0, x0), min(W, x1 + 1)):
grid[y][x] = color
def pixel(grid, x, y, color):
"""Set a single pixel."""
if 0 <= x < W and 0 <= y < H:
grid[y][x] = color
def draw_ship_body(grid, oy=0):
"""Draw the main spacecraft hull, offset vertically by oy pixels.
Ship anatomy (facing right):
- Engine block: cols 10-18, thick rear
- Main fuselage: cols 18-58, ~10px tall
- Cockpit: cols 58-66, with CYN canopy
- Nose: cols 66-72, pointed tip
- Upper fin: cols 22-38, extends upward
- Lower fin: cols 22-38, extends downward
"""
cy = 23 + oy # vertical center of fuselage
# ── Main fuselage (rows cy-5 to cy+4 = 10px tall) ──
for dy in range(-5, 5):
y = cy + dy
# Fuselage hull
if dy == -5:
# Top edge — light highlight
hline(grid, y, 20, 60, GYL)
elif dy == 4:
# Bottom edge — dark shadow
hline(grid, y, 20, 60, BLK)
elif dy == -4:
hline(grid, y, 18, 62, GYL)
elif dy == 3:
hline(grid, y, 18, 62, GYD)
elif dy == -1 or dy == 0:
# Blue racing stripe through center
hline(grid, y, 16, 64, BLU)
elif dy == -2:
hline(grid, y, 17, 63, GRY)
elif dy == 1:
hline(grid, y, 17, 63, GRY)
elif dy == 2:
hline(grid, y, 18, 62, GYD)
elif dy == -3:
hline(grid, y, 19, 61, GRY)
# ── Engine block (rear, left side) ──
for dy in range(-6, 7):
y = cy + dy
if -6 <= dy <= 6:
# Engine nacelle housing
if abs(dy) <= 3:
hline(grid, y, 10, 18, GYD)
elif abs(dy) <= 5:
hline(grid, y, 12, 18, GYD)
elif abs(dy) == 6:
hline(grid, y, 14, 17, GYD)
# Engine inner dark (thruster openings)
for dy in range(-3, 4):
y = cy + dy
if abs(dy) <= 2:
hline(grid, y, 10, 12, BLK)
elif abs(dy) == 3:
pixel(grid, 11, y, BLK)
pixel(grid, 12, y, BLK)
# Engine highlight on top
hline(grid, cy - 4, 13, 17, GRY)
hline(grid, cy - 5, 14, 17, GRY)
# ── Cockpit canopy ──
# Canopy frame (CYD) and glass (CYN)
for dy in range(-4, 3):
y = cy + dy
if dy == -4:
hline(grid, y, 58, 63, CYD)
elif dy == -3:
hline(grid, y, 59, 65, CYD)
hline(grid, y, 60, 64, CYN)
elif dy == -2:
hline(grid, y, 60, 66, CYD)
hline(grid, y, 61, 65, CYN)
pixel(grid, 63, y, WHT) # glint
elif dy == -1:
hline(grid, y, 61, 67, CYD)
hline(grid, y, 62, 66, CYN)
elif dy == 0:
hline(grid, y, 61, 67, CYD)
hline(grid, y, 62, 66, CYN)
pixel(grid, 64, y, WHT) # glint
elif dy == 1:
hline(grid, y, 60, 66, CYD)
hline(grid, y, 61, 65, CYN)
elif dy == 2:
hline(grid, y, 58, 64, CYD)
# ── Nose (pointed tip facing right) ──
for dy in range(-3, 4):
y = cy + dy
if dy == 0:
hline(grid, y, 66, 73, GYL) # center line
elif abs(dy) == 1:
hline(grid, y, 66, 71, GRY)
elif abs(dy) == 2:
hline(grid, y, 65, 69, GYD)
elif abs(dy) == 3:
hline(grid, y, 65, 67, BLK)
# Nose tip highlight
pixel(grid, 73, cy, WHT)
pixel(grid, 72, cy, WHT)
# ── Upper wing fin ──
for i in range(8):
y = cy - 6 - i
x0 = 26 + i
x1 = 40 - i
if x0 <= x1:
hline(grid, y, x0, x1, GYD)
# Highlight top edge
if i < 7:
pixel(grid, x0, y, GYL)
# BLU accent
if x1 - x0 > 2:
pixel(grid, (x0 + x1) // 2, y, BLU)
# Wing fin tip accent
pixel(grid, 33, cy - 13, BLU)
# ── Lower wing fin ──
for i in range(8):
y = cy + 5 + i
x0 = 26 + i
x1 = 40 - i
if x0 <= x1:
hline(grid, y, x0, x1, GYD)
# Shadow bottom edge
if i < 7:
pixel(grid, x0, y, BLK)
# BLU accent
if x1 - x0 > 2:
pixel(grid, (x0 + x1) // 2, y, BLD)
# ── Panel detail lines ──
for dy in [-3, 2]:
y = cy + dy
for x in [30, 42, 52]:
pixel(grid, x, y, GYD if dy < 0 else BLK)
def draw_landing_gear(grid, oy=0, length=4):
"""Draw two landing gear struts below the hull."""
cy = 23 + oy
gear_top = cy + 5 # just below hull bottom
# Front gear (col 52)
for dy in range(length):
y = gear_top + dy
pixel(grid, 52, y, GYD)
pixel(grid, 53, y, GYD)
# Foot
hline(grid, gear_top + length, 51, 54, BLK)
# Rear gear (col 26)
for dy in range(length):
y = gear_top + dy
pixel(grid, 26, y, GYD)
pixel(grid, 27, y, GYD)
# Foot
hline(grid, gear_top + length, 25, 28, BLK)
def draw_thrusters(grid, oy=0, intensity=0):
"""Draw thruster flames behind the engine.
intensity: 0=off, 1=dim, 2=medium, 3=bright
"""
if intensity == 0:
return
cy = 23 + oy
if intensity >= 3:
# Bright: long flame with white core
for dy in range(-2, 3):
y = cy + dy
if abs(dy) == 0:
hline(grid, y, 1, 9, YLW)
hline(grid, y, 3, 7, WHT)
elif abs(dy) == 1:
hline(grid, y, 3, 9, ORG)
hline(grid, y, 4, 8, YLW)
elif abs(dy) == 2:
hline(grid, y, 5, 9, ORD)
# Flame tip flicker
pixel(grid, 1, cy, WHT)
pixel(grid, 2, cy, YLW)
elif intensity >= 2:
# Medium flame
for dy in range(-1, 2):
y = cy + dy
if dy == 0:
hline(grid, y, 4, 9, YLW)
hline(grid, y, 5, 8, WHT)
else:
hline(grid, y, 5, 9, ORG)
hline(grid, y, 6, 8, YLW)
pixel(grid, 4, cy - 2, ORD)
pixel(grid, 4, cy + 2, ORD)
elif intensity >= 1:
# Dim glow
for dy in range(-1, 2):
y = cy + dy
if dy == 0:
hline(grid, y, 7, 9, ORD)
pixel(grid, 8, y, ORG)
else:
pixel(grid, 8, y, ORD)
pixel(grid, 9, y, ORD)
def apply_tilt(grid, tilt_pixels):
"""Shift the left side of the image down by tilt_pixels to simulate
nose-up tilt. This is a crude per-column vertical shift."""
if tilt_pixels == 0:
return grid
new_grid = make_frame()
for x in range(W):
# Linear interpolation: left edge shifts down by tilt_pixels,
# right edge stays the same
shift = int(tilt_pixels * (1.0 - x / W))
for y in range(H):
src_y = y - shift
if 0 <= src_y < H:
new_grid[y][x] = grid[src_y][x]
return new_grid
def make_landed():
"""Frame: ship landed on ground, gear down, no thrust."""
grid = make_frame()
draw_ship_body(grid, oy=0)
draw_landing_gear(grid, oy=0, length=5)
return grid
def make_landing1():
"""Frame 1: ship high, tilted nose-up, bright thrusters."""
grid = make_frame()
draw_ship_body(grid, oy=-8)
draw_thrusters(grid, oy=-8, intensity=3)
grid = apply_tilt(grid, 3)
return grid
def make_landing2():
"""Frame 2: ship lower, less tilt, medium thrusters."""
grid = make_frame()
draw_ship_body(grid, oy=-4)
draw_thrusters(grid, oy=-4, intensity=2)
grid = apply_tilt(grid, 1)
return grid
def make_landing3():
"""Frame 3: ship nearly level, near ground, gear extending, dim thrusters."""
grid = make_frame()
draw_ship_body(grid, oy=-2)
draw_landing_gear(grid, oy=-2, length=3)
draw_thrusters(grid, oy=-2, intensity=1)
return grid
def make_landing4():
"""Frame 4: ship touching down, gear extended, no thrust."""
grid = make_frame()
draw_ship_body(grid, oy=0)
draw_landing_gear(grid, oy=0, length=5)
# Nearly identical to landed — maybe a very slight glow
pixel(grid, 9, 23, ORD) # residual engine heat
return grid
def grid_to_image(grid):
"""Convert a pixel grid to a PIL Image."""
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
for y in range(H):
for x in range(W):
img.putpixel((x, y), grid[y][x])
return img
def main():
frames = [
make_landed(),
make_landing1(),
make_landing2(),
make_landing3(),
make_landing4(),
]
# Combine into horizontal spritesheet
sheet = Image.new("RGBA", (W * len(frames), H), (0, 0, 0, 0))
for i, frame in enumerate(frames):
img = grid_to_image(frame)
sheet.paste(img, (i * W, 0))
out_path = "assets/sprites/spacecraft.png"
sheet.save(out_path)
print(f"Saved spritesheet: {out_path} ({sheet.width}x{sheet.height})")
if __name__ == "__main__":
main()