mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
A complete, playable Pong game split across examples/pong/*.ne files
and pulled in from a top-level examples/pong.ne. Features:
- **Title screen** with a 3-option menu (CPU VS CPU / 1 PLAYER /
2 PLAYERS), a cursor sprite, blinking "PRESS A" prompt, brisk
title march on pulse 2, and autopilot that auto-confirms CPU VS
CPU after 45 frames of no input so the headless jsnes golden
harness reaches gameplay by frame 180.
- **Ball physics** with signed-magnitude velocity (u8 magnitude +
sign bit per axis), wall bounce at top/bottom, paddle AABB
collision with push-out, and score-out detection at left/right
exits.
- **Multi-ball** via parallel ball_* arrays (MAX_BALLS = 3). Each
ball scores a point independently; the round continues until the
last ball exits the playfield.
- **CPU AI** that tracks the nearest active ball heading toward its
side with a per-frame step, 4 px dead zone, and CPU_SPEED = 1 so
rallies can end naturally.
- **Three powerup types** that spawn every ~4 seconds, bounce off
all four walls, and are caught by paddle AABB overlap:
1. LONG — extends the catching paddle from 24 → 40 px for 5 hits
2. FAST — doubles ball x-velocity on the catcher's next hit
3. MULTI — spawns two extra balls on the catcher's next hit
- **Victory** at first-to-7 with a "PLAYER N WINS" banner and the
builtin fanfare, auto-returning to Title.
- **Audio**: 5 user-declared sfx (WallBounce, PaddleHit, Score,
PowerSpawn, PowerCatch) plus a title march and the builtin
fanfare for victory.
Source layout mirrors examples/war:
examples/pong.ne top-level game shell
examples/pong/PLAN.md living design doc
examples/pong/constants.ne layout + gameplay constants
examples/pong/assets.ne 45-tile Tileset (paddles, ball, alphabet,
digits, cursor, center-line, powerup icons)
examples/pong/audio.ne sfx + music declarations
examples/pong/state.ne all mutable globals
examples/pong/rng.ne 8-bit Galois LFSR
examples/pong/render.ne draw helpers
examples/pong/input.ne paddle step (human + CPU AI)
examples/pong/ball.ne multi-ball physics + paddle collision
examples/pong/powerup.ne powerup entity (spawn, bounce, catch, apply)
examples/pong/title_state.ne state Title + menu
examples/pong/play_state.ne state Playing (P_SERVE/P_PLAY/P_POINT)
examples/pong/victory_state.ne state Victory
Verification:
- 616 compiler unit tests pass (cargo test --all-targets)
- cargo fmt / cargo clippy --all-targets -- -D warnings clean
- 33/33 emulator harness goldens match
- examples/pong.nes builds byte-identically from source
https://claude.ai/code/session_0134F5uwDEVTes2Ee9S7JeXy
288 lines
9.6 KiB
Text
288 lines
9.6 KiB
Text
// pong/ball.ne — multi-ball physics.
|
|
//
|
|
// Balls live in parallel arrays indexed by slot (ball_x[i], etc.)
|
|
// so swapping between single-ball M3 and multi-ball M4 is purely a
|
|
// matter of which slots are active — the physics code is the
|
|
// same. Velocity is stored as (magnitude, sign) per axis because
|
|
// NEScript's u8 arithmetic can't express negatives directly; sign
|
|
// 0 = positive (+x right / +y down), sign 1 = negative.
|
|
//
|
|
// Update order inside update_ball:
|
|
// 1. Move the ball by its velocity
|
|
// 2. Bounce off top / bottom walls
|
|
// 3. Test paddle collisions (left + right)
|
|
// 4. Test score-out on left / right exits
|
|
//
|
|
// The `i` parameter is the ball slot, 0..MAX_BALLS-1.
|
|
|
|
// ── Serve ────────────────────────────────────────────────
|
|
//
|
|
// Reset ball slot 0 to the centre of the playfield with a fresh
|
|
// random velocity, aimed toward the `target_side` that just lost
|
|
// a point (so the serve is into the loser's side, classic Pong).
|
|
// Every other ball slot is cleared. Called on game start and
|
|
// after every scored point.
|
|
fun serve_ball(target_side: u8) {
|
|
// Wipe every slot first so M4's multi-ball leftovers never
|
|
// carry through a score.
|
|
var i: u8 = 0
|
|
while i < MAX_BALLS {
|
|
ball_active[i] = 0
|
|
i += 1
|
|
}
|
|
|
|
// Centre the primary ball.
|
|
ball_x[0] = 124
|
|
ball_y[0] = 112
|
|
ball_dx[0] = BALL_BASE_DX
|
|
ball_dy[0] = BALL_BASE_DY
|
|
|
|
// Horizontal direction is toward target_side. SIDE_LEFT = 0
|
|
// means the ball travels left toward the left paddle, so x
|
|
// sign = 1 (negative). Right paddle = sign 0.
|
|
if target_side == SIDE_LEFT {
|
|
ball_dx_sign[0] = 1
|
|
} else {
|
|
ball_dx_sign[0] = 0
|
|
}
|
|
|
|
// Vertical direction picked by a random bit to avoid the
|
|
// same rally pattern every serve. RNG LSB = 0 → +y, 1 → -y.
|
|
var r: u8 = rng_next()
|
|
ball_dy_sign[0] = r & 1
|
|
|
|
ball_active[0] = 1
|
|
}
|
|
|
|
// ── Score handling ───────────────────────────────────────
|
|
//
|
|
// Called when a ball exits the playfield past a paddle. The
|
|
// scoring side gets +1. With multi-ball, the round continues
|
|
// until ALL active balls are out. Only when the last ball exits
|
|
// does the phase machine drop into P_POINT for the serve pause.
|
|
fun on_score(scoring_side: u8) {
|
|
score[scoring_side] += 1
|
|
play Score
|
|
|
|
// Count remaining active balls (the exiting ball has already
|
|
// been marked inactive before this call).
|
|
var remaining: u8 = 0
|
|
var j: u8 = 0
|
|
while j < MAX_BALLS {
|
|
if ball_active[j] == 1 {
|
|
remaining += 1
|
|
}
|
|
j += 1
|
|
}
|
|
if remaining == 0 {
|
|
// Last ball out — enter the P_POINT pause. The
|
|
// serving_side records the scorer; P_POINT flips it so
|
|
// the serve aims at the loser.
|
|
serving_side = scoring_side
|
|
phase = P_POINT
|
|
phase_timer = 0
|
|
// Clear the powerup so it doesn't linger during the
|
|
// inter-round pause.
|
|
powerup_kind = PWR_NONE
|
|
powerup_cooldown = 0
|
|
}
|
|
}
|
|
|
|
// ── Multi-ball spawn on paddle hit ───────────────────────
|
|
//
|
|
// When paddle_multi[side] is set, a paddle hit spawns two extra
|
|
// balls at the hit point with mirrored y velocities. This
|
|
// function is called from within check_paddle_hit's hit branch.
|
|
// Returns immediately if no multi flag is set. The flag clears
|
|
// on use.
|
|
fun spawn_multi_balls(i: u8, side: u8) {
|
|
if paddle_multi[side] == 0 {
|
|
return
|
|
}
|
|
paddle_multi[side] = 0
|
|
|
|
// Find two free slots.
|
|
var slot1: u8 = 0
|
|
var slot2: u8 = 0
|
|
var found1: u8 = 0
|
|
var found2: u8 = 0
|
|
var k: u8 = 0
|
|
while k < MAX_BALLS {
|
|
if ball_active[k] == 0 {
|
|
if found1 == 0 {
|
|
slot1 = k
|
|
found1 = 1
|
|
} else {
|
|
if found2 == 0 {
|
|
slot2 = k
|
|
found2 = 1
|
|
}
|
|
}
|
|
}
|
|
k += 1
|
|
}
|
|
|
|
// Spawn each extra ball as a copy of the hit ball with a
|
|
// mirrored or offset y direction.
|
|
if found1 == 1 {
|
|
ball_active[slot1] = 1
|
|
ball_x[slot1] = ball_x[i]
|
|
ball_y[slot1] = ball_y[i]
|
|
ball_dx[slot1] = ball_dx[i]
|
|
ball_dy[slot1] = ball_dy[i]
|
|
ball_dx_sign[slot1] = ball_dx_sign[i]
|
|
// Mirror y sign from the source ball.
|
|
if ball_dy_sign[i] == 0 {
|
|
ball_dy_sign[slot1] = 1
|
|
} else {
|
|
ball_dy_sign[slot1] = 0
|
|
}
|
|
}
|
|
if found2 == 1 {
|
|
ball_active[slot2] = 1
|
|
ball_x[slot2] = ball_x[i]
|
|
ball_y[slot2] = ball_y[i]
|
|
ball_dx[slot2] = ball_dx[i]
|
|
ball_dy[slot2] = ball_dy[i]
|
|
ball_dx_sign[slot2] = ball_dx_sign[i]
|
|
// Same y direction as the source (so we have 3 distinct
|
|
// trajectories: original, mirrored, and parallel).
|
|
ball_dy_sign[slot2] = ball_dy_sign[i]
|
|
// Offset the y slightly to avoid all three landing on
|
|
// the same pixel.
|
|
if ball_y[slot2] + 6 < PLAYFIELD_BOTTOM {
|
|
ball_y[slot2] += 6
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Paddle collision ─────────────────────────────────────
|
|
//
|
|
// Classic AABB overlap test against the paddle on `side`. On a
|
|
// hit we flip the ball's x velocity sign, push the ball out of
|
|
// the paddle to avoid double-bounces, and play the hit sfx.
|
|
//
|
|
// Powerup effects consumed on hit:
|
|
// - LONG: paddle_long already decrements
|
|
// - FAST: paddle_fast → doubles ball_dx for the rest of this ball's life
|
|
// - MULTI: paddle_multi → spawns 2 extra balls at the hit point
|
|
fun check_paddle_hit(i: u8, side: u8) {
|
|
var px: u8 = LEFT_PADDLE_X
|
|
if side == SIDE_RIGHT {
|
|
px = RIGHT_PADDLE_X
|
|
}
|
|
var py: u8 = paddle_y[side]
|
|
var ph: u8 = PADDLE_H
|
|
if paddle_long[side] > 0 {
|
|
ph = PADDLE_H_LONG
|
|
}
|
|
|
|
var bx: u8 = ball_x[i]
|
|
var by: u8 = ball_y[i]
|
|
|
|
// Four-way AABB overlap test. The operands are small u8
|
|
// values so the additions can't overflow past 255.
|
|
if bx + BALL_SIZE > px and bx < px + PADDLE_W and by + BALL_SIZE > py and by < py + ph {
|
|
if side == SIDE_LEFT {
|
|
ball_x[i] = px + PADDLE_W
|
|
ball_dx_sign[i] = 0
|
|
} else {
|
|
ball_x[i] = px - BALL_SIZE
|
|
ball_dx_sign[i] = 1
|
|
}
|
|
|
|
// ── Consume long-paddle hit ──────────────────
|
|
if paddle_long[side] > 0 {
|
|
paddle_long[side] -= 1
|
|
}
|
|
// ── Consume fast-ball flag ───────────────────
|
|
if paddle_fast[side] > 0 {
|
|
paddle_fast[side] = 0
|
|
ball_dx[i] = BALL_FAST_DX
|
|
}
|
|
// ── Consume multi-ball flag ──────────────────
|
|
spawn_multi_balls(i, side)
|
|
|
|
play PaddleHit
|
|
}
|
|
}
|
|
|
|
// ── Per-ball update ──────────────────────────────────────
|
|
fun update_ball(i: u8) {
|
|
if ball_active[i] == 0 {
|
|
return
|
|
}
|
|
|
|
// ── 1. Move ──────────────────────────────────────
|
|
if ball_dx_sign[i] == 0 {
|
|
ball_x[i] += ball_dx[i]
|
|
} else {
|
|
ball_x[i] -= ball_dx[i]
|
|
}
|
|
if ball_dy_sign[i] == 0 {
|
|
ball_y[i] += ball_dy[i]
|
|
} else {
|
|
ball_y[i] -= ball_dy[i]
|
|
}
|
|
|
|
// ── 2. Wall bounce ───────────────────────────────
|
|
//
|
|
// Top: ball moving up and the top edge is now above the
|
|
// playfield (u8 comparison, safe because PLAYFIELD_TOP = 16
|
|
// and ball speeds are 1-2 so ball_y can't wrap around).
|
|
if ball_dy_sign[i] == 1 {
|
|
if ball_y[i] < PLAYFIELD_TOP {
|
|
ball_y[i] = PLAYFIELD_TOP
|
|
ball_dy_sign[i] = 0
|
|
play WallBounce
|
|
}
|
|
}
|
|
// Bottom: ball moving down and the bottom edge has crossed
|
|
// PLAYFIELD_BOTTOM.
|
|
if ball_dy_sign[i] == 0 {
|
|
if ball_y[i] + BALL_SIZE > PLAYFIELD_BOTTOM {
|
|
ball_y[i] = PLAYFIELD_BOTTOM - BALL_SIZE
|
|
ball_dy_sign[i] = 1
|
|
play WallBounce
|
|
}
|
|
}
|
|
|
|
// ── 3. Paddle collision ──────────────────────────
|
|
//
|
|
// Only check the paddle the ball is travelling toward.
|
|
// This is both faster and avoids the edge case of the
|
|
// ball hitting a paddle moving away from it.
|
|
if ball_dx_sign[i] == 1 {
|
|
check_paddle_hit(i, SIDE_LEFT)
|
|
} else {
|
|
check_paddle_hit(i, SIDE_RIGHT)
|
|
}
|
|
|
|
// ── 4. Score-out ─────────────────────────────────
|
|
//
|
|
// "ball_x < 8" / "ball_x > 240" is safe because paddles
|
|
// live at x = 16 and x = 232 — a ball that has moved past
|
|
// the paddle edge with max speed 2 lands at ball_x ∈ [6, 14]
|
|
// or [234, 242] before the next frame, never close enough
|
|
// to 0 or 255 to wrap the u8.
|
|
if ball_dx_sign[i] == 1 {
|
|
if ball_x[i] < 8 {
|
|
ball_active[i] = 0
|
|
on_score(SIDE_RIGHT)
|
|
}
|
|
} else {
|
|
if ball_x[i] > 240 {
|
|
ball_active[i] = 0
|
|
on_score(SIDE_LEFT)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Sweep every active ball ──────────────────────────────
|
|
fun step_balls() {
|
|
var i: u8 = 0
|
|
while i < MAX_BALLS {
|
|
update_ball(i)
|
|
i += 1
|
|
}
|
|
}
|