Initial commit
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
||||
# Build output
|
||||
/target/
|
||||
|
||||
# The game reads its data from the original ROM at runtime; never commit it.
|
||||
*.gb
|
||||
|
||||
# Headless test output
|
||||
/out/
|
||||
*.wav
|
||||
|
||||
# Editors / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*~
|
||||
Generated
+2724
File diff suppressed because it is too large
Load Diff
+13
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "opus"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Daedalian Opus, ported from its Game Boy disassembly to Rust"
|
||||
|
||||
[dependencies]
|
||||
winit = "0.30"
|
||||
pixels = "0.17"
|
||||
cpal = "0.18"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
@@ -0,0 +1,80 @@
|
||||
# Daedalian Opus, ported to Rust
|
||||
|
||||
A rewrite of the Game Boy game as ordinary Rust, translated by hand from the
|
||||
disassembly in `../disassembly/`. Unlike `../rust/` (a static recompilation that
|
||||
still runs the original instructions against emulated registers and memory),
|
||||
this is the game itself: the screens, the puzzle rules, the map, the story and
|
||||
the music sequencer are Rust functions over named state, so they can be read
|
||||
and changed.
|
||||
|
||||
```
|
||||
cargo run --release # looks for "Daedalian Opus (USA).gb" here or in ..
|
||||
cargo run --release -- --rom PATH
|
||||
```
|
||||
|
||||
Controls: arrows = D-pad, Z = A, X = B, Enter = Start, Right Shift / Backspace = Select, Esc = quit.
|
||||
|
||||
## What comes from where
|
||||
|
||||
The program contains no game content. At startup `rom.rs` parses the ROM
|
||||
into typed data: levels (`Level`: starting pieces, board layout, cursor
|
||||
start, music, pieces needed, which piece drops on the map), piece shapes,
|
||||
screen layouts (tilemap draw scripts), story text, passwords, tile graphics
|
||||
and the music byte code. Small numbers that are really code (coordinates,
|
||||
tile numbers for digits, timings) live in the Rust source as named constants.
|
||||
|
||||
## Layout
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `src/game/mod.rs` | `Game` state, the top-level flow (title → story → map → puzzle → ...), the per-frame tasks, the clear-time clock |
|
||||
| `src/game/puzzle.rs` | the puzzle: picking up, moving, rotating, flipping and placing pieces; the win check; reset; the level-clear screen |
|
||||
| `src/game/map.rs` | the map between levels: walking, castle doors, the bridge, the password bird, the piece that drops from the sky |
|
||||
| `src/game/menus.rs` | title, how to play, password entry, puzzle list |
|
||||
| `src/game/story.rs` | story screens and the ending |
|
||||
| `src/sound.rs` | the game's sound driver: an 8-channel byte-code sequencer (format documented at the top) |
|
||||
| `src/apu.rs` | the Game Boy sound chip, as the instrument the driver plays |
|
||||
| `src/video.rs` | tile graphics, two background maps, 40 sprites, palettes, and the renderer |
|
||||
| `src/rom.rs` | ROM loading and parsing |
|
||||
| `src/input.rs` | buttons, with the game's d-pad auto-repeat |
|
||||
| `src/main.rs` | window (`winit` + `pixels`), audio (`cpal`), frame pacing |
|
||||
| `src/headless.rs` | scripted runs for testing |
|
||||
|
||||
The game code is written the way the original is structured: straight-line
|
||||
code that calls `wait_frame()` whenever it needs the next frame (to animate,
|
||||
or to wait for a button). It runs on its own thread; `wait_frame` sends the
|
||||
finished picture and sound to the host and blocks until the host sends the
|
||||
next frame's buttons.
|
||||
|
||||
The display model (tiles, maps, sprites) is kept because it's what the art is
|
||||
made of, so the game still draws by placing tiles and sprites. Map addresses
|
||||
like `0x9800` remain the way cells are named, since the screen layout scripts
|
||||
in the ROM use them.
|
||||
|
||||
## Faithfulness
|
||||
|
||||
It's checked against the recompiled build in `../rust/` with the headless
|
||||
runner. Same inputs give the same screens (title, every menu, story pages,
|
||||
map walks, levels 1-3 and 36 solved, the ending), and the sound driver's chip
|
||||
register writes match frame for frame. Known differences:
|
||||
|
||||
- Loading a screen takes no time here, where the original spent a few frames
|
||||
copying graphics with the display off, so the port runs a few frames ahead.
|
||||
- The original queued tile updates to the next vblank; here they happen
|
||||
immediately, so some changes appear one frame sooner.
|
||||
- The original's unused "resume music" sound (id $96) points at garbage; it
|
||||
just resumes.
|
||||
|
||||
Original quirks are kept on purpose and marked `(sic)` in the source. For
|
||||
example, undoing a Select flip that doesn't fit uses Start's rule.
|
||||
|
||||
## Headless testing
|
||||
|
||||
```
|
||||
cargo run --release -- --headless OUT_DIR 120 start:2 90 shot:title probe goto:44:54
|
||||
```
|
||||
|
||||
Tokens: `N` run N frames; `BUTTON:N` hold a button for N frames (up down left
|
||||
right a b start select); `shot:NAME` save a PNG; `probe` print the level and
|
||||
cursor; `goto:YY:XX` steer the puzzle cursor (hex sprite coordinates). Audio
|
||||
goes to `OUT_DIR/audio.wav`.
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
//! The Game Boy sound chip: two pulse channels (one with sweep), the wave
|
||||
//! channel and the noise channel, mixed to stereo f32 samples.
|
||||
//!
|
||||
//! This is the instrument the game's music was written for; the game's own
|
||||
//! sound driver (`sound.rs`) plays it by setting its registers (NR10-NR52 and
|
||||
//! wave RAM, addressed $FF10-$FF3F as on the hardware) once per frame.
|
||||
|
||||
/// Clock the chip's timing is expressed in (the Game Boy CPU clock).
|
||||
pub const CLOCK_HZ: u64 = 4_194_304;
|
||||
const CPU_HZ: u64 = CLOCK_HZ;
|
||||
|
||||
pub const NR10: u16 = 0xFF10;
|
||||
pub const NR11: u16 = 0xFF11;
|
||||
pub const NR12: u16 = 0xFF12;
|
||||
pub const NR13: u16 = 0xFF13;
|
||||
pub const NR14: u16 = 0xFF14;
|
||||
pub const NR21: u16 = 0xFF16;
|
||||
pub const NR22: u16 = 0xFF17;
|
||||
pub const NR23: u16 = 0xFF18;
|
||||
pub const NR24: u16 = 0xFF19;
|
||||
pub const NR30: u16 = 0xFF1A;
|
||||
pub const NR32: u16 = 0xFF1C;
|
||||
pub const NR33: u16 = 0xFF1D;
|
||||
pub const NR34: u16 = 0xFF1E;
|
||||
pub const NR42: u16 = 0xFF21;
|
||||
pub const NR43: u16 = 0xFF22;
|
||||
pub const NR44: u16 = 0xFF23;
|
||||
pub const NR50: u16 = 0xFF24;
|
||||
pub const NR51: u16 = 0xFF25;
|
||||
pub const NR52: u16 = 0xFF26;
|
||||
pub const WAVE_RAM: u16 = 0xFF30;
|
||||
|
||||
const DUTY: [[u8; 8]; 4] = [
|
||||
[0, 0, 0, 0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
struct Envelope {
|
||||
volume: u8,
|
||||
increase: bool,
|
||||
period: u8,
|
||||
timer: u8,
|
||||
}
|
||||
|
||||
impl Envelope {
|
||||
fn load(&mut self, reg: u8) {
|
||||
self.volume = reg >> 4;
|
||||
self.increase = reg & 0x08 != 0;
|
||||
self.period = reg & 0x07;
|
||||
self.timer = self.period;
|
||||
}
|
||||
fn clock(&mut self) {
|
||||
if self.period == 0 {
|
||||
return;
|
||||
}
|
||||
if self.timer > 1 {
|
||||
self.timer -= 1;
|
||||
return;
|
||||
}
|
||||
self.timer = self.period;
|
||||
if self.increase && self.volume < 15 {
|
||||
self.volume += 1;
|
||||
} else if !self.increase && self.volume > 0 {
|
||||
self.volume -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pulse {
|
||||
on: bool,
|
||||
length: u16,
|
||||
length_enable: bool,
|
||||
env: Envelope,
|
||||
freq: u16,
|
||||
phase: f32,
|
||||
// sweep (channel 1 only)
|
||||
sweep_timer: u8,
|
||||
sweep_enabled: bool,
|
||||
shadow_freq: u16,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Wave {
|
||||
on: bool,
|
||||
length: u16,
|
||||
length_enable: bool,
|
||||
freq: u16,
|
||||
phase: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Noise {
|
||||
on: bool,
|
||||
length: u16,
|
||||
length_enable: bool,
|
||||
env: Envelope,
|
||||
lfsr: u16,
|
||||
acc: f32,
|
||||
}
|
||||
|
||||
pub struct Apu {
|
||||
regs: [u8; 0x30], // $FF10-$FF3F
|
||||
sq1: Pulse,
|
||||
sq2: Pulse,
|
||||
wave: Wave,
|
||||
noise: Noise,
|
||||
sample_rate: u32,
|
||||
/// clock cycles synthesized so far
|
||||
now: u64,
|
||||
samples_made: u64,
|
||||
last_cycle: u64,
|
||||
seq_cycles: u64,
|
||||
seq_step: u8,
|
||||
hp_l: (f32, f32),
|
||||
hp_r: (f32, f32),
|
||||
/// interleaved stereo samples waiting to be played
|
||||
pub out: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Apu {
|
||||
pub fn new(sample_rate: u32) -> Self {
|
||||
let mut a = Apu {
|
||||
regs: [0; 0x30],
|
||||
sq1: Pulse::default(),
|
||||
sq2: Pulse::default(),
|
||||
wave: Wave::default(),
|
||||
noise: Noise::default(),
|
||||
sample_rate,
|
||||
now: 0,
|
||||
samples_made: 0,
|
||||
last_cycle: 0,
|
||||
seq_cycles: 0,
|
||||
seq_step: 0,
|
||||
hp_l: (0.0, 0.0),
|
||||
hp_r: (0.0, 0.0),
|
||||
out: Vec::new(),
|
||||
};
|
||||
a.regs[0x16] = 0xF1; // NR52: on
|
||||
a.regs[0x14] = 0x77; // NR50
|
||||
a.regs[0x15] = 0xF3; // NR51
|
||||
a
|
||||
}
|
||||
|
||||
fn reg(&self, addr: u16) -> u8 {
|
||||
self.regs[(addr - 0xFF10) as usize]
|
||||
}
|
||||
|
||||
pub fn read(&self, addr: u16) -> u8 {
|
||||
if addr == 0xFF26 {
|
||||
return (self.reg(0xFF26) & 0x80)
|
||||
| 0x70
|
||||
| self.sq1.on as u8
|
||||
| (self.sq2.on as u8) << 1
|
||||
| (self.wave.on as u8) << 2
|
||||
| (self.noise.on as u8) << 3;
|
||||
}
|
||||
self.reg(addr)
|
||||
}
|
||||
|
||||
pub fn write(&mut self, addr: u16, v: u8) {
|
||||
let powered = self.reg(0xFF26) & 0x80 != 0;
|
||||
if !powered && addr != 0xFF26 && addr < 0xFF30 {
|
||||
return;
|
||||
}
|
||||
self.regs[(addr - 0xFF10) as usize] = v;
|
||||
match addr {
|
||||
0xFF11 => self.sq1.length = 64 - (v & 0x3F) as u16,
|
||||
0xFF12 => if v & 0xF8 == 0 { self.sq1.on = false },
|
||||
0xFF13 => self.sq1.freq = (self.sq1.freq & 0x700) | v as u16,
|
||||
0xFF14 => {
|
||||
self.sq1.freq = (self.sq1.freq & 0xFF) | ((v as u16 & 7) << 8);
|
||||
self.sq1.length_enable = v & 0x40 != 0;
|
||||
if v & 0x80 != 0 {
|
||||
let (env, nr10) = (self.reg(0xFF12), self.reg(0xFF10));
|
||||
Self::trigger_pulse(&mut self.sq1, env);
|
||||
self.sq1.shadow_freq = self.sq1.freq;
|
||||
let period = (nr10 >> 4) & 7;
|
||||
self.sq1.sweep_timer = if period == 0 { 8 } else { period };
|
||||
self.sq1.sweep_enabled = period != 0 || nr10 & 7 != 0;
|
||||
}
|
||||
}
|
||||
0xFF16 => self.sq2.length = 64 - (v & 0x3F) as u16,
|
||||
0xFF17 => if v & 0xF8 == 0 { self.sq2.on = false },
|
||||
0xFF18 => self.sq2.freq = (self.sq2.freq & 0x700) | v as u16,
|
||||
0xFF19 => {
|
||||
self.sq2.freq = (self.sq2.freq & 0xFF) | ((v as u16 & 7) << 8);
|
||||
self.sq2.length_enable = v & 0x40 != 0;
|
||||
if v & 0x80 != 0 {
|
||||
let env = self.reg(0xFF17);
|
||||
Self::trigger_pulse(&mut self.sq2, env);
|
||||
}
|
||||
}
|
||||
0xFF1A => if v & 0x80 == 0 { self.wave.on = false },
|
||||
0xFF1B => self.wave.length = 256 - v as u16,
|
||||
0xFF1D => self.wave.freq = (self.wave.freq & 0x700) | v as u16,
|
||||
0xFF1E => {
|
||||
self.wave.freq = (self.wave.freq & 0xFF) | ((v as u16 & 7) << 8);
|
||||
self.wave.length_enable = v & 0x40 != 0;
|
||||
if v & 0x80 != 0 {
|
||||
self.wave.on = self.reg(0xFF1A) & 0x80 != 0;
|
||||
if self.wave.length == 0 {
|
||||
self.wave.length = 256;
|
||||
}
|
||||
self.wave.phase = 0.0;
|
||||
}
|
||||
}
|
||||
0xFF20 => self.noise.length = 64 - (v & 0x3F) as u16,
|
||||
0xFF21 => if v & 0xF8 == 0 { self.noise.on = false },
|
||||
0xFF23 => {
|
||||
self.noise.length_enable = v & 0x40 != 0;
|
||||
if v & 0x80 != 0 {
|
||||
let env = self.reg(0xFF21);
|
||||
self.noise.on = env & 0xF8 != 0;
|
||||
if self.noise.length == 0 {
|
||||
self.noise.length = 64;
|
||||
}
|
||||
self.noise.env.load(env);
|
||||
self.noise.lfsr = 0x7FFF;
|
||||
}
|
||||
}
|
||||
0xFF26 => {
|
||||
if v & 0x80 == 0 {
|
||||
for r in &mut self.regs[..0x16] {
|
||||
*r = 0;
|
||||
}
|
||||
self.sq1.on = false;
|
||||
self.sq2.on = false;
|
||||
self.wave.on = false;
|
||||
self.noise.on = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_pulse(p: &mut Pulse, env: u8) {
|
||||
p.on = env & 0xF8 != 0;
|
||||
if p.length == 0 {
|
||||
p.length = 64;
|
||||
}
|
||||
p.env.load(env);
|
||||
}
|
||||
|
||||
fn sweep_calc(&mut self) -> u16 {
|
||||
let nr10 = self.reg(0xFF10);
|
||||
let delta = self.sq1.shadow_freq >> (nr10 & 7);
|
||||
let f = if nr10 & 0x08 != 0 {
|
||||
self.sq1.shadow_freq.wrapping_sub(delta)
|
||||
} else {
|
||||
self.sq1.shadow_freq + delta
|
||||
};
|
||||
if f > 2047 {
|
||||
self.sq1.on = false;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// 512 Hz frame sequencer: lengths, sweep, envelopes.
|
||||
fn sequencer_step(&mut self) {
|
||||
let step = self.seq_step;
|
||||
self.seq_step = (self.seq_step + 1) & 7;
|
||||
if step % 2 == 0 {
|
||||
for (on, len, en) in [
|
||||
(&mut self.sq1.on, &mut self.sq1.length, self.sq1.length_enable),
|
||||
(&mut self.sq2.on, &mut self.sq2.length, self.sq2.length_enable),
|
||||
(&mut self.wave.on, &mut self.wave.length, self.wave.length_enable),
|
||||
(&mut self.noise.on, &mut self.noise.length, self.noise.length_enable),
|
||||
] {
|
||||
if en && *len > 0 {
|
||||
*len -= 1;
|
||||
if *len == 0 {
|
||||
*on = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if step == 2 || step == 6 {
|
||||
let nr10 = self.reg(0xFF10);
|
||||
let period = (nr10 >> 4) & 7;
|
||||
if self.sq1.sweep_timer > 0 {
|
||||
self.sq1.sweep_timer -= 1;
|
||||
}
|
||||
if self.sq1.sweep_timer == 0 {
|
||||
self.sq1.sweep_timer = if period == 0 { 8 } else { period };
|
||||
if self.sq1.sweep_enabled && period != 0 {
|
||||
let f = self.sweep_calc();
|
||||
if f <= 2047 && nr10 & 7 != 0 {
|
||||
self.sq1.shadow_freq = f;
|
||||
self.sq1.freq = f;
|
||||
self.sweep_calc();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if step == 7 {
|
||||
self.sq1.env.clock();
|
||||
self.sq2.env.clock();
|
||||
self.noise.env.clock();
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize the next `cycles` clock cycles of sound into `out`.
|
||||
pub fn run(&mut self, cycles: u64) {
|
||||
self.now += cycles;
|
||||
let rate = self.sample_rate as f32;
|
||||
let target = self.now * self.sample_rate as u64 / CPU_HZ;
|
||||
while self.samples_made < target {
|
||||
self.samples_made += 1;
|
||||
let cyc = self.samples_made * CPU_HZ / self.sample_rate as u64;
|
||||
// frame sequencer runs every 8192 CPU cycles
|
||||
self.seq_cycles += cyc - self.last_cycle;
|
||||
self.last_cycle = cyc;
|
||||
while self.seq_cycles >= 8192 {
|
||||
self.seq_cycles -= 8192;
|
||||
self.sequencer_step();
|
||||
}
|
||||
|
||||
let mut ch = [0.0f32; 4];
|
||||
let duty1 = (self.reg(0xFF11) >> 6) as usize;
|
||||
let duty2 = (self.reg(0xFF16) >> 6) as usize;
|
||||
for (i, (p, duty)) in [(&mut self.sq1, duty1), (&mut self.sq2, duty2)].into_iter().enumerate() {
|
||||
let hz = 131072.0 / (2048 - p.freq) as f32;
|
||||
p.phase = (p.phase + hz * 8.0 / rate) % 8.0;
|
||||
if p.on {
|
||||
let high = DUTY[duty][p.phase as usize] != 0;
|
||||
ch[i] = p.env.volume as f32 / 15.0 * if high { 1.0 } else { -1.0 };
|
||||
}
|
||||
}
|
||||
{
|
||||
let hz = 65536.0 / (2048 - self.wave.freq) as f32;
|
||||
self.wave.phase = (self.wave.phase + hz * 32.0 / rate) % 32.0;
|
||||
let shift = match (self.reg(0xFF1C) >> 5) & 3 {
|
||||
0 => 8,
|
||||
n => n - 1,
|
||||
};
|
||||
if self.wave.on && shift < 8 {
|
||||
let idx = self.wave.phase as usize;
|
||||
let byte = self.regs[0x20 + idx / 2];
|
||||
let nib = if idx % 2 == 0 { byte >> 4 } else { byte & 0x0F };
|
||||
ch[2] = (nib >> shift) as f32 / 7.5 - 1.0;
|
||||
}
|
||||
}
|
||||
{
|
||||
let nr43 = self.reg(0xFF22);
|
||||
let div = match nr43 & 7 { 0 => 0.5, d => d as f32 };
|
||||
let hz = 524288.0 / div / (1u32 << ((nr43 >> 4) + 1)) as f32;
|
||||
self.noise.acc += hz / rate;
|
||||
while self.noise.acc >= 1.0 {
|
||||
self.noise.acc -= 1.0;
|
||||
let x = (self.noise.lfsr ^ (self.noise.lfsr >> 1)) & 1;
|
||||
self.noise.lfsr = (self.noise.lfsr >> 1) | (x << 14);
|
||||
if nr43 & 0x08 != 0 {
|
||||
self.noise.lfsr = (self.noise.lfsr & !0x40) | (x << 6);
|
||||
}
|
||||
}
|
||||
if self.noise.on {
|
||||
let high = self.noise.lfsr & 1 == 0;
|
||||
ch[3] = self.noise.env.volume as f32 / 15.0 * if high { 1.0 } else { -1.0 };
|
||||
}
|
||||
}
|
||||
|
||||
let nr50 = self.reg(0xFF24);
|
||||
let nr51 = self.reg(0xFF25);
|
||||
let (mut l, mut r) = (0.0, 0.0);
|
||||
for (i, v) in ch.iter().enumerate() {
|
||||
if nr51 & (0x10 << i) != 0 { l += v; }
|
||||
if nr51 & (0x01 << i) != 0 { r += v; }
|
||||
}
|
||||
l *= ((nr50 >> 4) & 7) as f32 + 1.0;
|
||||
r *= (nr50 & 7) as f32 + 1.0;
|
||||
let scale = 0.25 / 8.0;
|
||||
let l = Self::highpass(&mut self.hp_l, l * scale);
|
||||
let r = Self::highpass(&mut self.hp_r, r * scale);
|
||||
self.out.push(l);
|
||||
self.out.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn highpass(state: &mut (f32, f32), x: f32) -> f32 {
|
||||
let y = x - state.0 + 0.996 * state.1;
|
||||
*state = (x, y);
|
||||
y
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
//! The map screen between levels: the hero walks from the last castle to the
|
||||
//! next one. On the way a bridge may build itself, a bird flies over writing
|
||||
//! the level's password, and on some levels a new piece drops from the sky.
|
||||
|
||||
use super::*;
|
||||
use crate::input::*;
|
||||
use crate::rom::addr::*;
|
||||
use crate::sound::*;
|
||||
use crate::video::map_cell;
|
||||
|
||||
const LAST_LEVEL: u8 = 35;
|
||||
|
||||
// hero shapes
|
||||
const FACE_UP: u8 = 0x41;
|
||||
const STAND_RIGHT: u8 = 0x42;
|
||||
const STAND_LEFT: u8 = 0x43;
|
||||
const WALK_RIGHT: u8 = 0x44;
|
||||
const WALK_LEFT: u8 = 0x45;
|
||||
const LEAVE_CASTLE: u8 = 0x48;
|
||||
const ENTER_CASTLE: u8 = 0x4C;
|
||||
|
||||
const HERO_Y: u8 = 0x6C;
|
||||
const STEP: u8 = 4;
|
||||
const STEP_FRAMES: u8 = 11;
|
||||
/// where the bridge starts building, and where a new piece drops
|
||||
const BRIDGE_TRIGGER_X: u8 = 0x28;
|
||||
const PIECE_DROP_X: u8 = 0x50;
|
||||
|
||||
const BRIDGE_AT: u16 = map_cell(MAP0, 6, 13);
|
||||
const BRIDGE_TILES: [u8; 8] = [0x30, 0x31, 0x31, 0x31, 0x31, 0x31, 0x31, 0x32];
|
||||
|
||||
/// The bird flies in a 16-bit fixed-point x position; it writes one letter
|
||||
/// at each of these x positions.
|
||||
const MESSENGER_LETTER_X: [u8; 4] = [0x44, 0x4C, 0x54, 0x5C];
|
||||
const MESSENGER_DONE_X: u8 = 0xC0;
|
||||
const PASSWORD_AT: u16 = map_cell(MAP0, 8, 3);
|
||||
/// tile = ASCII + this
|
||||
const PASSWORD_TILE_OFFSET: u8 = 0x9D;
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub(super) enum BridgeState {
|
||||
#[default]
|
||||
Idle,
|
||||
Triggered,
|
||||
Building,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Bridge {
|
||||
pub state: BridgeState,
|
||||
built: u8,
|
||||
delay: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub(super) enum MessengerState {
|
||||
#[default]
|
||||
Idle,
|
||||
Launch,
|
||||
Flying,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Messenger {
|
||||
pub state: MessengerState,
|
||||
x: u16,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub(super) fn map_screen(&mut self) {
|
||||
let data = self.data.clone();
|
||||
let level = &data.levels[self.level as usize];
|
||||
self.clear_actors();
|
||||
if level.new_piece != 0 {
|
||||
self.s.actors[MARKER] = Actor::at(0x68, 0x56, 0x56);
|
||||
}
|
||||
self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x800));
|
||||
self.video.load(0x8800, data.gfx(GFX_FONT, 0x800));
|
||||
self.video.load(0x9000, data.gfx(GFX_MAP_TERRAIN, 0x800));
|
||||
self.video.fill_maps(0x33);
|
||||
let scripts = &data.scripts;
|
||||
self.video.draw(&scripts.map);
|
||||
let scene = match self.level {
|
||||
0 => 0,
|
||||
LAST_LEVEL => 2,
|
||||
_ => 1,
|
||||
};
|
||||
self.video.draw(&scripts.map_scenes[scene]);
|
||||
self.video.draw(&scripts.map_castles[level.map_castle as usize]);
|
||||
self.video.draw(&scripts.map_next_castles[level.map_next_castle as usize]);
|
||||
if self.level != 0 && self.level != LAST_LEVEL {
|
||||
// castle 12 is special and has no number on it
|
||||
if self.level != 12 {
|
||||
self.draw_castle_number(1, self.level);
|
||||
}
|
||||
if self.level + 1 != 12 {
|
||||
self.draw_castle_number(17, self.level + 1);
|
||||
}
|
||||
}
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(MUSIC_MAP);
|
||||
self.show(MAP0);
|
||||
}
|
||||
|
||||
/// Two-digit number, two tiles tall, at row 7.
|
||||
fn draw_castle_number(&mut self, col: u16, n: u8) {
|
||||
let (tens, ones) = (n / 10, n % 10);
|
||||
for (c, tile) in [(col, 0x6C + 2 * tens), (col + 1, 0x58 + 2 * ones)] {
|
||||
self.video.poke(map_cell(MAP0, c, 7), tile);
|
||||
self.video.poke(map_cell(MAP0, c, 8), tile + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk around until the hero goes into a castle.
|
||||
pub(super) fn map_walk(&mut self) {
|
||||
match self.level {
|
||||
0 => {
|
||||
self.s.actors[HERO] = Actor::at(HERO_Y, 0x38, STAND_RIGHT);
|
||||
(self.s.walk_left, self.s.walk_right) = (0x38, 0x78);
|
||||
}
|
||||
LAST_LEVEL => {
|
||||
(self.s.walk_left, self.s.walk_right) = (0x08, 0x64);
|
||||
self.leave_castle(Actor::at(HERO_Y, 0x08, LEAVE_CASTLE));
|
||||
self.s.messenger.state = MessengerState::Launch;
|
||||
}
|
||||
_ => {
|
||||
(self.s.walk_left, self.s.walk_right) = (0x10, 0x90);
|
||||
self.leave_castle(Actor::at(HERO_Y, 0x10, LEAVE_CASTLE));
|
||||
self.s.messenger.state = MessengerState::Launch;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
self.wait_frame();
|
||||
if self.try_enter_castle() {
|
||||
return;
|
||||
}
|
||||
self.walk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Up at a castle door: forward to the next level, or back to the
|
||||
/// previous one (when facing left at the left castle).
|
||||
fn try_enter_castle(&mut self) -> bool {
|
||||
if self.joy.pressed & UP == 0 {
|
||||
return false;
|
||||
}
|
||||
let hero = self.s.actors[HERO];
|
||||
if hero.x == self.s.walk_left {
|
||||
if self.level == 0 || hero.shape == STAND_RIGHT {
|
||||
return false;
|
||||
}
|
||||
self.enter_castle();
|
||||
self.level -= 1;
|
||||
return true;
|
||||
}
|
||||
if hero.x == self.s.walk_right {
|
||||
if self.level == LAST_LEVEL {
|
||||
self.climb_into_tower();
|
||||
} else {
|
||||
self.enter_castle();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Walks while the d-pad is held (this blocks, a step at a time).
|
||||
fn walk(&mut self) {
|
||||
if self.joy.held & RIGHT != 0 {
|
||||
self.s.actors[HERO].shape = WALK_RIGHT;
|
||||
loop {
|
||||
self.step(self.s.walk_right, STEP);
|
||||
if self.s.actors[HERO].x == BRIDGE_TRIGGER_X {
|
||||
self.s.bridge.state = BridgeState::Triggered;
|
||||
}
|
||||
if self.joy.held & RIGHT == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.s.actors[HERO].shape = STAND_RIGHT;
|
||||
} else if self.joy.held & LEFT != 0 {
|
||||
self.s.actors[HERO].shape = WALK_LEFT;
|
||||
loop {
|
||||
self.step(self.s.walk_left, STEP.wrapping_neg());
|
||||
if self.joy.held & LEFT == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.s.actors[HERO].shape = STAND_LEFT;
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, limit: u8, dx: u8) {
|
||||
self.play(SFX_STEP);
|
||||
self.wait_frames(STEP_FRAMES);
|
||||
let hero = &mut self.s.actors[HERO];
|
||||
hero.shape ^= 2; // swing legs
|
||||
if hero.x == limit {
|
||||
return;
|
||||
}
|
||||
hero.x = hero.x.wrapping_add(dx);
|
||||
self.check_piece_drop();
|
||||
}
|
||||
|
||||
/// Two frames of animation, four times each, then the next pair.
|
||||
fn animate_pair(&mut self, times: u8) {
|
||||
for _ in 0..times {
|
||||
self.play(SFX_STEP);
|
||||
self.wait_frames(12);
|
||||
self.s.actors[HERO].shape ^= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn leave_castle(&mut self, hero: Actor) {
|
||||
self.wait_frames(30);
|
||||
self.s.actors[HERO] = hero;
|
||||
self.animate_pair(4);
|
||||
self.s.actors[HERO].shape += 2;
|
||||
self.animate_pair(4);
|
||||
self.s.actors[HERO].shape = STAND_RIGHT;
|
||||
}
|
||||
|
||||
fn enter_castle(&mut self) {
|
||||
self.s.actors[HERO].shape = ENTER_CASTLE;
|
||||
self.animate_pair(4);
|
||||
self.s.actors[HERO].shape += 2;
|
||||
self.animate_pair(4);
|
||||
self.clear_actors();
|
||||
self.s.messenger.state = MessengerState::Idle;
|
||||
self.wait_frames(30);
|
||||
}
|
||||
|
||||
/// The last level's door is up a wall.
|
||||
fn climb_into_tower(&mut self) {
|
||||
self.s.actors[HERO].shape = ENTER_CASTLE;
|
||||
self.climb();
|
||||
self.s.actors[HERO].shape += 2;
|
||||
self.s.actors[HERO].y += 2;
|
||||
self.climb();
|
||||
self.s.actors[HERO].visible = false;
|
||||
self.wait_frames(30);
|
||||
}
|
||||
|
||||
fn climb(&mut self) {
|
||||
for _ in 0..6 {
|
||||
self.play(SFX_STEP);
|
||||
self.wait_frames(12);
|
||||
let hero = &mut self.s.actors[HERO];
|
||||
hero.shape ^= 1;
|
||||
hero.y -= 3;
|
||||
}
|
||||
}
|
||||
|
||||
/// Halfway along, some levels drop a new piece from the sky.
|
||||
fn check_piece_drop(&mut self) {
|
||||
if self.s.piece_drop_done || self.s.actors[HERO].x != PIECE_DROP_X {
|
||||
return;
|
||||
}
|
||||
let piece = self.data.levels[self.level as usize].new_piece;
|
||||
if piece != 0 {
|
||||
self.drop_piece(piece);
|
||||
}
|
||||
self.s.piece_drop_done = true;
|
||||
}
|
||||
|
||||
fn drop_piece(&mut self, piece: u8) {
|
||||
let facing = self.s.actors[HERO].shape;
|
||||
self.s.actors[HERO].shape = FACE_UP;
|
||||
self.s.actors[CURSOR] = Actor::at(0xE0, PIECE_DROP_X, 0);
|
||||
self.s.held = piece;
|
||||
self.play(SFX_CONFIRM);
|
||||
self.wait_frames(30);
|
||||
self.s.actors[MARKER].visible = false;
|
||||
loop {
|
||||
// tumble down
|
||||
self.play(SFX_MOVE_PIECE);
|
||||
self.wait_frames(4);
|
||||
self.s.held = (self.s.held & 0xFC) | (self.s.held.wrapping_add(1) & 3);
|
||||
let p = &mut self.s.actors[CURSOR];
|
||||
p.y = p.y.wrapping_add(4);
|
||||
if p.y == 0x50 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.play(SFX_PIECE_LANDS);
|
||||
for _ in 0..2 {
|
||||
self.set_palettes(0xE4); // flash
|
||||
self.wait_frames(10);
|
||||
self.set_palettes(PALETTE);
|
||||
self.wait_frames(10);
|
||||
}
|
||||
self.wait_frames(30);
|
||||
self.s.actors[CURSOR].visible = false;
|
||||
self.play(JINGLE_LEVEL_CLEAR);
|
||||
for _ in 0..2 {
|
||||
self.s.actors[HERO].shape = STAND_RIGHT;
|
||||
self.wait_frames(20);
|
||||
self.s.actors[HERO].shape = FACE_UP;
|
||||
self.wait_frames(20);
|
||||
}
|
||||
self.s.actors[HERO].shape = facing;
|
||||
}
|
||||
|
||||
// ---- per-frame map animations ----
|
||||
|
||||
pub(super) fn update_bridge(&mut self) {
|
||||
let b = &mut self.s.bridge;
|
||||
match b.state {
|
||||
BridgeState::Idle => {}
|
||||
BridgeState::Triggered => {
|
||||
// the first and last maps have no river, and it only builds once
|
||||
if self.level == 0 || self.level == LAST_LEVEL || b.built != 0 {
|
||||
b.state = BridgeState::Idle;
|
||||
} else {
|
||||
b.state = BridgeState::Building;
|
||||
b.delay = 1;
|
||||
self.sound.play(SFX_BRIDGE);
|
||||
}
|
||||
}
|
||||
BridgeState::Building => {
|
||||
b.delay -= 1;
|
||||
if b.delay != 0 {
|
||||
return;
|
||||
}
|
||||
b.delay = 5;
|
||||
let i = b.built;
|
||||
self.video.poke(BRIDGE_AT + i as u16, BRIDGE_TILES[i as usize]);
|
||||
if i + 1 == BRIDGE_TILES.len() as u8 {
|
||||
b.state = BridgeState::Idle;
|
||||
} else {
|
||||
b.built += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_marker_blink(&mut self) {
|
||||
self.s.marker_blink = self.s.marker_blink.wrapping_add(1);
|
||||
if self.s.marker_blink & 0x1F == 0 {
|
||||
self.s.actors[MARKER].shape ^= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_messenger(&mut self) {
|
||||
let m = &mut self.s.messenger;
|
||||
match m.state {
|
||||
MessengerState::Idle => {}
|
||||
MessengerState::Launch => {
|
||||
m.state = MessengerState::Flying;
|
||||
m.x = 0xF000;
|
||||
self.s.actors[MESSENGER] = Actor::at(0x24, 0xE0, 0x58);
|
||||
}
|
||||
MessengerState::Flying => {
|
||||
m.x = m.x.wrapping_add(0x80);
|
||||
let x = (m.x >> 8) as u8;
|
||||
let bird = &mut self.s.actors[MESSENGER];
|
||||
bird.x = x;
|
||||
if let Some(i) = MESSENGER_LETTER_X.iter().position(|&lx| lx == x) {
|
||||
let letter = self.data.passwords[self.level as usize - 1][i];
|
||||
self.video.poke(PASSWORD_AT + i as u16, letter.wrapping_add(PASSWORD_TILE_OFFSET));
|
||||
self.sound.play(SFX_PASSWORD_LETTER);
|
||||
} else if x == MESSENGER_DONE_X {
|
||||
m.state = MessengerState::Idle;
|
||||
} else {
|
||||
bird.shape ^= (x >> 4) & 1; // flap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Title screen, how-to-play, password entry and the puzzle list.
|
||||
|
||||
use super::*;
|
||||
use crate::input::*;
|
||||
use crate::rom::addr::*;
|
||||
use crate::sound::*;
|
||||
use crate::video::{MAP0, MAP1};
|
||||
|
||||
pub enum TitleChoice {
|
||||
Start,
|
||||
Password,
|
||||
HowToPlay,
|
||||
}
|
||||
|
||||
/// Title menu arrow rows.
|
||||
const TITLE_START_Y: u8 = 0x60;
|
||||
const TITLE_PASSWORD_Y: u8 = 0x70;
|
||||
|
||||
/// Password screen: a 7x4 grid of letters A-Z with a cursor.
|
||||
const LETTER_TOP: u8 = 0x40;
|
||||
const LETTER_BOTTOM: u8 = 0x70;
|
||||
const LETTER_LEFT: u8 = 0x30;
|
||||
const LETTER_RIGHT: u8 = 0x90;
|
||||
const PASSWORD_ENTRY: u16 = 0x9868; // where the typed letters appear
|
||||
const BLANK_LETTER: u8 = 0x7D;
|
||||
const FIRST_LETTER_TILE: u8 = 0x5E; // 'A'
|
||||
|
||||
/// Puzzle list: two pages (maps 0 and 1) of 6 rows x 3 columns.
|
||||
const LIST_TOP: u8 = 0x30;
|
||||
const LIST_BOTTOM: u8 = 0x80;
|
||||
const LIST_LEFT: u8 = 0x10;
|
||||
const LIST_RIGHT: u8 = 0x70;
|
||||
const LIST_PAGE: u8 = 18;
|
||||
const PUZZLE_LIST_LEVEL: u8 = 36;
|
||||
|
||||
impl Game {
|
||||
pub(super) fn title_screen(&mut self) -> TitleChoice {
|
||||
let data = self.data.clone();
|
||||
self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x1800));
|
||||
self.clear_actors();
|
||||
self.s.actors[MENU_CURSOR] = Actor::at(TITLE_START_Y, 0x38, 0x55);
|
||||
// map 0 is the title; map 1 is the same without "PUSH START"
|
||||
self.video.fill_maps(0xEB);
|
||||
self.video.draw(&data.scripts.title);
|
||||
self.video.copy_map(MAP0, MAP1);
|
||||
self.video.draw(&data.scripts.title_alt);
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(MUSIC_TITLE);
|
||||
self.show(MAP0);
|
||||
self.joy.held = 0xFF; // ignore buttons already down
|
||||
|
||||
let mut blink: u8 = 0x60;
|
||||
loop {
|
||||
self.wait_frame();
|
||||
let p = self.joy.pressed;
|
||||
let arrow = &mut self.s.actors[MENU_CURSOR].y;
|
||||
if p & A != 0 {
|
||||
return TitleChoice::HowToPlay;
|
||||
} else if p & SELECT != 0 {
|
||||
*arrow = if *arrow == TITLE_START_Y { TITLE_PASSWORD_Y } else { TITLE_START_Y };
|
||||
} else if p & START != 0 {
|
||||
self.play(SFX_CONFIRM);
|
||||
return if self.s.actors[MENU_CURSOR].y == TITLE_PASSWORD_Y {
|
||||
TitleChoice::Password
|
||||
} else {
|
||||
TitleChoice::Start
|
||||
};
|
||||
} else if p & UP != 0 {
|
||||
*arrow = TITLE_START_Y;
|
||||
} else if p & DOWN != 0 {
|
||||
*arrow = TITLE_PASSWORD_Y;
|
||||
} else {
|
||||
// flash "PUSH START": 32 frames on, 32 off
|
||||
blink = blink.wrapping_add(1);
|
||||
if blink == 0 {
|
||||
blink = 0x60;
|
||||
self.show(MAP0);
|
||||
} else if blink == 0x80 {
|
||||
blink = 0xE0;
|
||||
self.show(MAP1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.play(SFX_MENU_MOVE);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn how_to_play(&mut self) {
|
||||
let data = self.data.clone();
|
||||
self.video.load(0x8800, data.gfx(GFX_HOW_TO_PLAY, 0x800));
|
||||
self.video.load(0x9000, data.gfx(GFX_FONT, 0x800));
|
||||
self.video.fill_maps(0x7F);
|
||||
self.video.draw(&data.scripts.how_to_play);
|
||||
self.clear_actors();
|
||||
self.set_palettes(PALETTE);
|
||||
self.show(MAP0);
|
||||
self.play(MUSIC_MAP);
|
||||
loop {
|
||||
self.wait_frame();
|
||||
if self.joy.pressed & (A | START) != 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `self.level` from a valid password.
|
||||
pub(super) fn password_screen(&mut self) {
|
||||
let data = self.data.clone();
|
||||
self.load_menu_tiles();
|
||||
self.video.fill_maps(0x7F);
|
||||
self.clear_actors();
|
||||
self.video.draw(&data.scripts.password);
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(MUSIC_TITLE);
|
||||
self.show(MAP0);
|
||||
self.wait_frame();
|
||||
loop {
|
||||
let mut entry = [BLANK_LETTER; 4];
|
||||
let mut len = 0;
|
||||
self.s.actors[HERO] = Actor::at(LETTER_TOP, LETTER_LEFT, 0x53);
|
||||
loop {
|
||||
for (i, &t) in entry.iter().enumerate() {
|
||||
self.video.poke(PASSWORD_ENTRY + i as u16, t);
|
||||
}
|
||||
self.wait_frame();
|
||||
if len == 4 {
|
||||
self.wait_frames(30);
|
||||
if let Some(level) = self.check_password(&entry) {
|
||||
self.level = level;
|
||||
return;
|
||||
}
|
||||
self.play(SFX_ERROR);
|
||||
break;
|
||||
}
|
||||
self.move_letter_cursor();
|
||||
let cursor = self.s.actors[HERO];
|
||||
if self.joy.pressed & A != 0 {
|
||||
entry[len] = letter_at(cursor.y, cursor.x).wrapping_add(FIRST_LETTER_TILE);
|
||||
len += 1;
|
||||
} else if self.joy.pressed & B != 0 && len > 0 {
|
||||
len -= 1;
|
||||
entry[len] = BLANK_LETTER;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_menu_tiles(&mut self) {
|
||||
let font = self.data.clone();
|
||||
self.video.load(0x8000, font.gfx(GFX_FONT, 0x800));
|
||||
self.video.load(0x9000, font.gfx(GFX_FONT, 0x800));
|
||||
}
|
||||
|
||||
fn move_letter_cursor(&mut self) {
|
||||
let r = self.joy.repeat;
|
||||
let Actor { mut y, mut x, .. } = self.s.actors[HERO];
|
||||
let mut moved = 0;
|
||||
if r & RIGHT != 0 {
|
||||
if x != LETTER_RIGHT {
|
||||
x += 0x10;
|
||||
moved += 1;
|
||||
if letter_at(y, x) >= 26 {
|
||||
y = 0x60; // off the end of the last row: go up one
|
||||
}
|
||||
}
|
||||
} else if r & LEFT != 0 && x != LETTER_LEFT {
|
||||
x -= 0x10;
|
||||
moved += 1;
|
||||
}
|
||||
if r & UP != 0 {
|
||||
if y != LETTER_TOP {
|
||||
y -= 0x10;
|
||||
moved += 1;
|
||||
}
|
||||
} else if r & DOWN != 0 && y != LETTER_BOTTOM {
|
||||
y += 0x10;
|
||||
moved += 1;
|
||||
if letter_at(y, x) >= 26 {
|
||||
x = 0x70; // 'Z'
|
||||
}
|
||||
}
|
||||
for _ in 0..moved {
|
||||
self.play(SFX_MENU_MOVE); // once per axis moved
|
||||
}
|
||||
let c = &mut self.s.actors[HERO];
|
||||
(c.y, c.x) = (y, x);
|
||||
}
|
||||
|
||||
fn check_password(&self, entry: &[u8; 4]) -> Option<u8> {
|
||||
let typed = entry.map(|t| t.wrapping_sub(FIRST_LETTER_TILE).wrapping_add(b'A'));
|
||||
self.data.passwords.iter().position(|p| *p == typed).map(|i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// Level select, reached with the password after the last level.
|
||||
pub(super) fn puzzle_list(&mut self) {
|
||||
if self.level != PUZZLE_LIST_LEVEL {
|
||||
return;
|
||||
}
|
||||
let data = self.data.clone();
|
||||
self.load_menu_tiles();
|
||||
self.video.fill_maps(0x7F);
|
||||
self.clear_actors();
|
||||
self.video.draw(&data.scripts.puzzle_list);
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(MUSIC_TITLE);
|
||||
self.show(MAP0);
|
||||
self.s.actors[MENU_CURSOR] = Actor::at(LIST_TOP, LIST_LEFT, 0x54);
|
||||
let mut page = 0;
|
||||
loop {
|
||||
self.wait_frame();
|
||||
if let Some(p) = self.move_list_cursor(page) {
|
||||
page = p;
|
||||
continue;
|
||||
}
|
||||
let p = self.joy.pressed;
|
||||
if p & A != 0 || (p & SELECT == 0 && p & START != 0) {
|
||||
let Actor { y, x, .. } = self.s.actors[MENU_CURSOR];
|
||||
let col = match x {
|
||||
LIST_LEFT => 0,
|
||||
0x40 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
self.level = page + ((y - LIST_TOP) >> 4) * 3 + col;
|
||||
self.play(SFX_CONFIRM);
|
||||
return;
|
||||
}
|
||||
if p & SELECT != 0 {
|
||||
self.play(SFX_MENU_MOVE);
|
||||
if page != 0 {
|
||||
self.show(MAP0);
|
||||
page = 0;
|
||||
} else {
|
||||
self.show(MAP1);
|
||||
page = LIST_PAGE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the new page if the cursor moved off the top/bottom of one.
|
||||
fn move_list_cursor(&mut self, page: u8) -> Option<u8> {
|
||||
let r = self.joy.repeat;
|
||||
let c = &mut self.s.actors[MENU_CURSOR];
|
||||
let mut moved = 0;
|
||||
if r & DOWN != 0 {
|
||||
if c.y != LIST_BOTTOM {
|
||||
c.y += 0x10;
|
||||
moved += 1;
|
||||
} else if page == 0 {
|
||||
c.y = LIST_TOP;
|
||||
self.video.display = Display::On(MAP1);
|
||||
self.sound.play(SFX_MENU_MOVE);
|
||||
return Some(LIST_PAGE);
|
||||
}
|
||||
} else if r & UP != 0 {
|
||||
if c.y != LIST_TOP {
|
||||
c.y -= 0x10;
|
||||
moved += 1;
|
||||
} else if page != 0 {
|
||||
c.y = LIST_BOTTOM;
|
||||
self.video.display = Display::On(MAP0);
|
||||
self.sound.play(SFX_MENU_MOVE);
|
||||
return Some(0);
|
||||
}
|
||||
}
|
||||
if r & LEFT != 0 {
|
||||
if c.x != LIST_LEFT {
|
||||
c.x -= 0x30;
|
||||
moved += 1;
|
||||
}
|
||||
} else if r & RIGHT != 0 && c.x != LIST_RIGHT {
|
||||
c.x += 0x30;
|
||||
moved += 1;
|
||||
}
|
||||
for _ in 0..moved {
|
||||
self.sound.play(SFX_MENU_MOVE);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Letter number (0 = A) under the password cursor.
|
||||
fn letter_at(y: u8, x: u8) -> u8 {
|
||||
let row = y.wrapping_sub(LETTER_TOP) >> 4;
|
||||
let col = x.wrapping_sub(LETTER_LEFT) >> 4;
|
||||
row.wrapping_mul(7).wrapping_add(col)
|
||||
}
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
//! The game itself.
|
||||
//!
|
||||
//! The game is written the way the original is: as straight-line code that
|
||||
//! calls [`Game::wait_frame`] whenever it wants the next frame (to animate, to
|
||||
//! wait for a button, ...). It runs on its own thread; `wait_frame` hands the
|
||||
//! finished picture and sound to the host and blocks until the host sends the
|
||||
//! next frame's buttons.
|
||||
//!
|
||||
//! Modules: `menus` (title, how to play, password, puzzle list), `story`
|
||||
//! (story screens and the ending), `map` (the overworld walk between levels)
|
||||
//! and `puzzle` (the puzzle itself and the level-clear screen).
|
||||
|
||||
mod map;
|
||||
mod menus;
|
||||
mod puzzle;
|
||||
mod story;
|
||||
|
||||
use std::sync::mpsc::{Receiver, SyncSender};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::apu::{Apu, CLOCK_HZ};
|
||||
use crate::input::Joypad;
|
||||
use crate::rom::{GameData, Shape};
|
||||
use crate::sound::SoundDriver;
|
||||
use crate::video::{Display, Sprite, Video, MAP0, SCREEN_H, SCREEN_W};
|
||||
|
||||
/// Clock cycles per frame (the Game Boy runs at ~59.73 frames per second).
|
||||
pub const FRAME_CYCLES: u64 = 70_224;
|
||||
pub const FRAME_HZ: f64 = CLOCK_HZ as f64 / FRAME_CYCLES as f64;
|
||||
|
||||
/// The normal palette: shade n for color n, reversed (the game's art is
|
||||
/// stored with 0 = black).
|
||||
const PALETTE: u8 = 0x1B;
|
||||
|
||||
// ---- actor slots (multi-tile sprites, drawn every frame) ----
|
||||
/// puzzle cursor / held piece; also the piece that drops on the map
|
||||
const CURSOR: usize = 0;
|
||||
/// menu arrow on the title screen and puzzle list
|
||||
const MENU_CURSOR: usize = 1;
|
||||
/// the hero on the map, the storyteller, the password cursor
|
||||
const HERO: usize = 2;
|
||||
/// "something here" marker on the map; the second figure in story scenes
|
||||
const MARKER: usize = 3;
|
||||
/// the bird that brings the password on the map
|
||||
const MESSENGER: usize = 4;
|
||||
const ACTOR_SLOTS: usize = 12;
|
||||
|
||||
/// A sprite made of one shape's cells, positioned at (y, x) in sprite
|
||||
/// coordinates (screen + 16, screen + 8).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Actor {
|
||||
pub visible: bool,
|
||||
pub y: u8,
|
||||
pub x: u8,
|
||||
pub shape: u8,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
const fn at(y: u8, x: u8, shape: u8) -> Actor {
|
||||
Actor { visible: true, y, x, shape }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a placed piece is and which board tiles it covers.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct PieceSlot {
|
||||
y: u8,
|
||||
x: u8,
|
||||
/// the board tiles under the piece's cells
|
||||
under: [u8; 6],
|
||||
}
|
||||
|
||||
/// The puzzle board: which piece id covers each background map cell.
|
||||
type Board = Box<[u8; 0x400]>;
|
||||
|
||||
/// Scratch state that the original keeps in $C000-$CFFF and wipes at the
|
||||
/// start of each puzzle (and at the title screen).
|
||||
struct Scratch {
|
||||
actors: [Actor; ACTOR_SLOTS],
|
||||
/// id of the piece in hand (type << 4 | orientation), 0 = none
|
||||
held: u8,
|
||||
/// one slot per piece type
|
||||
slots: [PieceSlot; 16],
|
||||
slots_backup: [PieceSlot; 16],
|
||||
board: Board,
|
||||
board_backup: Board,
|
||||
bridge: map::Bridge,
|
||||
marker_blink: u8,
|
||||
story: u8,
|
||||
piece_drop_done: bool,
|
||||
timer: Timer,
|
||||
messenger: map::Messenger,
|
||||
/// how far the hero can walk on the map
|
||||
walk_left: u8,
|
||||
walk_right: u8,
|
||||
}
|
||||
|
||||
impl Default for Scratch {
|
||||
fn default() -> Self {
|
||||
Scratch {
|
||||
actors: Default::default(),
|
||||
held: 0,
|
||||
slots: Default::default(),
|
||||
slots_backup: Default::default(),
|
||||
board: Box::new([0; 0x400]),
|
||||
board_backup: Box::new([0; 0x400]),
|
||||
bridge: Default::default(),
|
||||
marker_blink: 0,
|
||||
story: 0,
|
||||
piece_drop_done: false,
|
||||
timer: Timer::default(),
|
||||
messenger: Default::default(),
|
||||
walk_left: 0,
|
||||
walk_right: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
enum TimerMode {
|
||||
#[default]
|
||||
Hidden,
|
||||
Running,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// The clear-time clock in the top right corner.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Timer {
|
||||
mode: TimerMode,
|
||||
frames: u8,
|
||||
/// seconds, tens of seconds, minutes, tens of minutes, hours
|
||||
digits: [u8; 5],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Palettes {
|
||||
bg: u8,
|
||||
obj0: u8,
|
||||
obj1: u8,
|
||||
/// palette 1 inverts every 16 frames (makes the cursor blink)
|
||||
blink: u8,
|
||||
}
|
||||
|
||||
/// What the game hands the host each frame.
|
||||
pub struct Frame {
|
||||
/// one shade (0 = white .. 3 = black) per pixel
|
||||
pub pixels: Vec<u8>,
|
||||
/// interleaved stereo samples
|
||||
pub audio: Vec<f32>,
|
||||
/// a peek at the game state, for testing
|
||||
pub probe: Probe,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Probe {
|
||||
pub level: u8,
|
||||
pub cursor_y: u8,
|
||||
pub cursor_x: u8,
|
||||
pub held: u8,
|
||||
}
|
||||
|
||||
/// The game thread's end of the connection to the host.
|
||||
pub struct Link {
|
||||
pub frames: SyncSender<Frame>,
|
||||
pub buttons: Receiver<u8>,
|
||||
}
|
||||
|
||||
/// Unwinds the game thread when the host goes away.
|
||||
struct Quit;
|
||||
|
||||
pub struct Game {
|
||||
data: Arc<GameData>,
|
||||
link: Link,
|
||||
video: Video,
|
||||
apu: Apu,
|
||||
sound: SoundDriver,
|
||||
joy: Joypad,
|
||||
level: u8,
|
||||
pieces_needed: u8,
|
||||
palettes: Palettes,
|
||||
/// sprites built this frame; the display picks them up at the next vblank
|
||||
oam: [Sprite; 40],
|
||||
s: Scratch,
|
||||
}
|
||||
|
||||
/// Run the game on a new thread.
|
||||
pub fn spawn(data: Arc<GameData>, sample_rate: u32, link: Link) -> std::thread::JoinHandle<()> {
|
||||
std::thread::spawn(move || {
|
||||
let mut game = Game::new(data, sample_rate, link);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| game.run()));
|
||||
if let Err(e) = result {
|
||||
if !e.is::<Quit>() {
|
||||
std::panic::resume_unwind(e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Game {
|
||||
fn new(data: Arc<GameData>, sample_rate: u32, link: Link) -> Game {
|
||||
Game {
|
||||
sound: SoundDriver::new(data.clone()),
|
||||
data,
|
||||
link,
|
||||
video: Video::new(),
|
||||
apu: Apu::new(sample_rate),
|
||||
joy: Joypad::default(),
|
||||
level: 0,
|
||||
pieces_needed: 0,
|
||||
palettes: Palettes::default(),
|
||||
oam: [Sprite::default(); 40],
|
||||
s: Scratch::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self) -> ! {
|
||||
loop {
|
||||
self.reset();
|
||||
match self.title_screen() {
|
||||
menus::TitleChoice::HowToPlay => {
|
||||
self.how_to_play();
|
||||
continue;
|
||||
}
|
||||
menus::TitleChoice::Password => {
|
||||
self.password_screen();
|
||||
self.puzzle_list();
|
||||
}
|
||||
menus::TitleChoice::Start => {}
|
||||
}
|
||||
self.play_levels();
|
||||
self.ending();
|
||||
}
|
||||
}
|
||||
|
||||
/// Story, map, puzzle, repeat; returns after the last level.
|
||||
fn play_levels(&mut self) {
|
||||
loop {
|
||||
self.s.timer.mode = TimerMode::Hidden;
|
||||
self.story_screen();
|
||||
self.story_text();
|
||||
self.map_screen();
|
||||
self.map_walk();
|
||||
self.puzzle_screen();
|
||||
self.s.timer.mode = TimerMode::Running;
|
||||
loop {
|
||||
self.wait_frame();
|
||||
self.check_reset_puzzle();
|
||||
self.move_cursor();
|
||||
self.piece_action();
|
||||
if self.pieces_in_place() == self.pieces_needed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.s.timer.mode = TimerMode::Stopped;
|
||||
self.level_clear();
|
||||
if self.level as usize + 1 >= crate::rom::LEVEL_COUNT {
|
||||
self.s.timer.mode = TimerMode::Hidden;
|
||||
return;
|
||||
}
|
||||
self.level += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Back to power-on state (the title screen clears all of RAM).
|
||||
fn reset(&mut self) {
|
||||
self.video.display = Display::Off;
|
||||
self.s = Scratch::default();
|
||||
self.level = 0;
|
||||
self.pieces_needed = 0;
|
||||
self.palettes = Palettes::default();
|
||||
self.joy = Joypad::default();
|
||||
self.sound = SoundDriver::new(self.data.clone());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frames
|
||||
|
||||
/// Finish this frame and wait for the next one.
|
||||
fn wait_frame(&mut self) {
|
||||
let mut pixels = vec![0; SCREEN_W * SCREEN_H];
|
||||
self.video.render(&mut pixels);
|
||||
let probe = Probe {
|
||||
level: self.level,
|
||||
cursor_y: self.s.actors[CURSOR].y,
|
||||
cursor_x: self.s.actors[CURSOR].x,
|
||||
held: self.s.held,
|
||||
};
|
||||
let frame = Frame { pixels, audio: std::mem::take(&mut self.apu.out), probe };
|
||||
if self.link.frames.send(frame).is_err() {
|
||||
std::panic::resume_unwind(Box::new(Quit));
|
||||
}
|
||||
let buttons = match self.link.buttons.recv() {
|
||||
Ok(b) => b,
|
||||
Err(_) => std::panic::resume_unwind(Box::new(Quit)),
|
||||
};
|
||||
|
||||
// things the original does every vblank
|
||||
self.video.sprites = self.oam;
|
||||
self.update_palettes();
|
||||
self.update_cursor_shape();
|
||||
self.build_sprites();
|
||||
self.joy.update(buttons);
|
||||
self.update_bridge();
|
||||
self.update_timer();
|
||||
self.draw_timer();
|
||||
self.update_marker_blink();
|
||||
self.update_messenger();
|
||||
self.sound.update(&mut self.apu);
|
||||
self.apu.run(FRAME_CYCLES);
|
||||
}
|
||||
|
||||
fn wait_frames(&mut self, n: u8) {
|
||||
for _ in 0..n {
|
||||
self.wait_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_palettes(&mut self) {
|
||||
let p = &mut self.palettes;
|
||||
self.video.bg_palette = p.bg;
|
||||
self.video.obj_palettes = [p.obj0, p.obj1];
|
||||
let (blink, wrapped) = p.blink.overflowing_add(0x10);
|
||||
p.blink = blink;
|
||||
if wrapped {
|
||||
p.obj1 ^= 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_palettes(&mut self, v: u8) {
|
||||
self.palettes.bg = v;
|
||||
self.palettes.obj0 = v;
|
||||
self.palettes.obj1 = v;
|
||||
}
|
||||
|
||||
/// Turn the screen on showing the given background map.
|
||||
fn show(&mut self, map: u16) {
|
||||
self.video.display = Display::On(map);
|
||||
}
|
||||
|
||||
fn play(&mut self, sound: u8) {
|
||||
self.sound.play(sound);
|
||||
}
|
||||
|
||||
fn shape(&self, n: u8) -> &Shape {
|
||||
&self.data.shapes[n as usize]
|
||||
}
|
||||
|
||||
fn clear_actors(&mut self) {
|
||||
self.s.actors = Default::default();
|
||||
self.s.held = 0;
|
||||
}
|
||||
|
||||
/// The cursor actor shows the held piece, or the cursor itself (which
|
||||
/// changes shape over the piece tray on the right).
|
||||
fn update_cursor_shape(&mut self) {
|
||||
let a = &mut self.s.actors[CURSOR];
|
||||
a.shape = if self.s.held != 0 {
|
||||
self.data.piece_shape(self.s.held)
|
||||
} else if a.x >= 0xA4 {
|
||||
0x5D
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
|
||||
fn build_sprites(&mut self) {
|
||||
let mut sprites = [Sprite::default(); 40];
|
||||
let mut n = 0;
|
||||
for a in self.s.actors.iter().filter(|a| a.visible) {
|
||||
let shape = &self.data.shapes[a.shape as usize];
|
||||
for cell in &shape.cells {
|
||||
let (dy, dx) = cell.sprite_offset();
|
||||
if let Some(s) = sprites.get_mut(n) {
|
||||
*s = Sprite { y: a.y.wrapping_add(dy), x: a.x.wrapping_add(dx), tile: cell.tile, attr: shape.attr };
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
self.oam = sprites;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- timer
|
||||
|
||||
fn update_timer(&mut self) {
|
||||
let t = &mut self.s.timer;
|
||||
if t.mode != TimerMode::Running {
|
||||
return;
|
||||
}
|
||||
t.frames += 1;
|
||||
if t.frames < 60 {
|
||||
return;
|
||||
}
|
||||
t.frames = 0;
|
||||
for (digit, limit) in t.digits.iter_mut().zip([10, 6, 10, 6, 10]) {
|
||||
*digit += 1;
|
||||
if *digit < limit {
|
||||
return;
|
||||
}
|
||||
*digit = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-tile-tall digits in the last 10 sprite slots: h mm ss.
|
||||
fn draw_timer(&mut self) {
|
||||
let t = self.s.timer;
|
||||
if t.mode == TimerMode::Hidden {
|
||||
return;
|
||||
}
|
||||
for (i, x) in [0x88, 0x80, 0x70, 0x68, 0x58].into_iter().enumerate() {
|
||||
let tile = t.digits[i] * 2 + 0x64;
|
||||
self.oam[30 + 2 * i] = Sprite { y: 0x10, x, tile, attr: 0 };
|
||||
self.oam[31 + 2 * i] = Sprite { y: 0x18, x, tile: tile + 1, attr: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map cell under a point in sprite coordinates.
|
||||
fn cell_at(y: u8, x: u8) -> u16 {
|
||||
let row = (y.wrapping_sub(16) >> 3) as u16;
|
||||
let col = (x.wrapping_sub(8) >> 3) as u16;
|
||||
MAP0 + row * 32 + col
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! The puzzle: pick up pieces, turn and flip them, and fit them all into the
|
||||
//! board. Then the level-clear screen.
|
||||
//!
|
||||
//! The board is drawn into background map 0. Board floor tiles are $80 and
|
||||
//! up, and the area to fill is the floor tiles at $F0 and up; everything below
|
||||
//! $80 is a wall. `Scratch::board` records which piece covers each map cell,
|
||||
//! and each piece type's slot remembers where it is and the floor tiles it
|
||||
//! covers, so they can be put back when it's picked up.
|
||||
|
||||
use super::*;
|
||||
use crate::input::*;
|
||||
use crate::rom::addr::*;
|
||||
use crate::sound::*;
|
||||
use crate::video::{map_cell, MAP1};
|
||||
|
||||
const FLOOR: u8 = 0x80;
|
||||
const TARGET_FLOOR: u8 = 0xF0;
|
||||
|
||||
/// Pieces must stay within this area (sprite coordinates).
|
||||
const AREA_TOP: u8 = 0x20;
|
||||
const AREA_BOTTOM: u8 = 0x99;
|
||||
const AREA_LEFT: u8 = 0x08;
|
||||
const AREA_RIGHT: u8 = 0xA1;
|
||||
|
||||
/// The empty cursor's range.
|
||||
const CURSOR_MAX_Y: u8 = 0x9C;
|
||||
const CURSOR_MIN_Y: u8 = 0x25;
|
||||
const CURSOR_MIN_X: u8 = 0x0D;
|
||||
const CURSOR_MAX_X: u8 = 0xA4;
|
||||
|
||||
/// Slot for a piece id (one per piece type).
|
||||
fn slot(piece_id: u8) -> usize {
|
||||
(piece_id.wrapping_sub(0x10) >> 4) as usize
|
||||
}
|
||||
|
||||
/// A board cell covered by a shape: the map address and the tile.
|
||||
struct Cell {
|
||||
addr: u16,
|
||||
tile: u8,
|
||||
y: u8,
|
||||
x: u8,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub(super) fn puzzle_screen(&mut self) {
|
||||
let data = self.data.clone();
|
||||
let level = &data.levels[self.level as usize];
|
||||
self.wait_frame();
|
||||
self.video.load(0x8000, data.gfx(GFX_SPRITES, 0x1800));
|
||||
self.s = Scratch::default();
|
||||
self.video.fill_maps(0x84);
|
||||
self.video.draw(&data.scripts.puzzle_hud);
|
||||
self.draw_puzzle_number();
|
||||
self.video.draw(&level.board);
|
||||
for p in &level.pieces {
|
||||
self.s.held = p.id;
|
||||
self.s.actors[CURSOR].y = p.y.wrapping_add(16);
|
||||
self.s.actors[CURSOR].x = p.x.wrapping_add(8);
|
||||
self.update_cursor_shape();
|
||||
let cells = self.held_cells();
|
||||
self.cover(&cells);
|
||||
self.put_down(&cells);
|
||||
}
|
||||
self.init_cursor();
|
||||
// keep the starting position for "reset"
|
||||
self.s.slots_backup = self.s.slots;
|
||||
self.s.board_backup = self.s.board.clone();
|
||||
self.video.copy_map(MAP0, MAP1);
|
||||
self.pieces_needed = level.pieces_needed;
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(level.music);
|
||||
self.show(MAP0);
|
||||
}
|
||||
|
||||
/// Puzzle number in the HUD, two tiles tall, left aligned.
|
||||
fn draw_puzzle_number(&mut self) {
|
||||
let n = self.level + 1;
|
||||
let (tens, ones) = (n / 10, n % 10);
|
||||
let digits: &[u8] = if tens != 0 { &[tens, ones] } else { &[ones] };
|
||||
let mut cols = [(0x21, 0x29); 2]; // blank
|
||||
for (i, d) in digits.iter().enumerate() {
|
||||
cols[i] = (0x60 + 2 * d, 0x61 + 2 * d);
|
||||
}
|
||||
for (i, (top, bottom)) in cols.into_iter().enumerate() {
|
||||
self.video.poke(map_cell(MAP0, 5 + i as u16, 0), top);
|
||||
self.video.poke(map_cell(MAP0, 5 + i as u16, 1), bottom);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_cursor(&mut self) {
|
||||
let (y, x) = self.data.levels[self.level as usize].cursor;
|
||||
self.s.actors[CURSOR] = Actor::at((y & 0xF8) | 4, (x & 0xF8) | 4, 0);
|
||||
self.s.held = 0;
|
||||
}
|
||||
|
||||
/// The held piece's cells, at its position snapped to the 8px grid.
|
||||
fn held_cells(&self) -> Vec<Cell> {
|
||||
let a = self.s.actors[CURSOR];
|
||||
let shape = a.shape & 0x7F;
|
||||
if shape == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let (y, x) = (a.y.wrapping_add(4) & 0xF8, a.x.wrapping_add(4) & 0xF8);
|
||||
self.shape(shape)
|
||||
.cells
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (dy, dx) = c.board_offset();
|
||||
let (cy, cx) = (y.wrapping_add(dy), x.wrapping_add(dx));
|
||||
Cell { addr: cell_at(cy, cx), tile: c.tile, y: cy, x: cx }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record the floor tiles under the held piece in its slot. Fails (true)
|
||||
/// on the first cell that isn't floor.
|
||||
fn cover(&mut self, cells: &[Cell]) -> bool {
|
||||
let slot = slot(self.s.held);
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
let under = self.video.peek(c.addr);
|
||||
if under < FLOOR {
|
||||
return true;
|
||||
}
|
||||
if let Some(u) = self.s.slots[slot].under.get_mut(i) {
|
||||
*u = under;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Stamp the held piece onto the board.
|
||||
fn put_down(&mut self, cells: &[Cell]) {
|
||||
if cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
let a = self.s.actors[CURSOR];
|
||||
let id = self.data.shape_piece_id(a.shape & 0x7F);
|
||||
for c in cells {
|
||||
self.s.board[(c.addr - MAP0) as usize] = id;
|
||||
self.video.poke(c.addr, c.tile);
|
||||
}
|
||||
let slot = &mut self.s.slots[slot(id)];
|
||||
(slot.y, slot.x) = (a.y.wrapping_add(4) & 0xF8, a.x.wrapping_add(4) & 0xF8);
|
||||
self.play(SFX_PLACE);
|
||||
}
|
||||
|
||||
/// Would `shape` at (y, x) stick out of the play area?
|
||||
fn out_of_bounds(&self, shape: u8, y: u8, x: u8) -> bool {
|
||||
self.shape(shape).cells.iter().any(|c| {
|
||||
let (dy, dx) = c.board_offset();
|
||||
let (cy, cx) = (y.wrapping_add(dy), x.wrapping_add(dx));
|
||||
!(AREA_TOP..AREA_BOTTOM).contains(&cy) || !(AREA_LEFT..AREA_RIGHT).contains(&cx)
|
||||
})
|
||||
}
|
||||
|
||||
/// A pressed while B is held: put every piece back where it started.
|
||||
pub(super) fn check_reset_puzzle(&mut self) {
|
||||
if self.joy.pressed & A == 0 || self.joy.held & B == 0 {
|
||||
return;
|
||||
}
|
||||
self.play(SFX_ROTATE);
|
||||
self.s.actors[CURSOR].visible = false;
|
||||
self.wipe_rows(MAP1, MAP0);
|
||||
self.s.slots = self.s.slots_backup;
|
||||
self.s.board = self.s.board_backup.clone();
|
||||
self.init_cursor();
|
||||
}
|
||||
|
||||
/// Copy the play area from one map to another, a row every two frames.
|
||||
fn wipe_rows(&mut self, from: u16, to: u16) {
|
||||
for row in 0..18 {
|
||||
let row_tiles: Vec<u8> = (0..20).map(|c| self.video.peek(from + row * 32 + c)).collect();
|
||||
self.wait_frame();
|
||||
for (c, &t) in row_tiles.iter().enumerate() {
|
||||
self.video.poke(to + row * 32 + c as u16, t);
|
||||
}
|
||||
self.wait_frame();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn move_cursor(&mut self) {
|
||||
let r = self.joy.repeat;
|
||||
if self.s.held == 0 {
|
||||
let c = &mut self.s.actors[CURSOR];
|
||||
if r & DOWN != 0 && c.y < CURSOR_MAX_Y {
|
||||
c.y += 8;
|
||||
}
|
||||
if r & UP != 0 && c.y >= CURSOR_MIN_Y {
|
||||
c.y -= 8;
|
||||
}
|
||||
if r & LEFT != 0 && c.x >= CURSOR_MIN_X {
|
||||
c.x -= 8;
|
||||
}
|
||||
if r & RIGHT != 0 && c.x < CURSOR_MAX_X {
|
||||
c.x += 8;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// carrying a piece: it can't leave the play area
|
||||
let shape = self.s.actors[CURSOR].shape;
|
||||
let Actor { mut y, mut x, .. } = self.s.actors[CURSOR];
|
||||
for (button, dy, dx) in [(DOWN, 8, 0), (UP, 0xF8, 0), (LEFT, 0, 0xF8), (RIGHT, 0, 8)] {
|
||||
if r & button != 0 {
|
||||
self.play(SFX_MOVE_PIECE);
|
||||
let (ny, nx) = (y.wrapping_add(dy), x.wrapping_add(dx));
|
||||
if !self.out_of_bounds(shape, ny, nx) {
|
||||
(y, x) = (ny, nx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = &mut self.s.actors[CURSOR];
|
||||
(c.y, c.x) = (y, x);
|
||||
}
|
||||
|
||||
/// A: pick up / put down. B: rotate. Select / Start: flip.
|
||||
pub(super) fn piece_action(&mut self) {
|
||||
let p = self.joy.pressed;
|
||||
if p & A != 0 {
|
||||
if self.s.held == 0 {
|
||||
self.pick_up();
|
||||
} else {
|
||||
self.drop_held();
|
||||
}
|
||||
} else if p & B != 0 {
|
||||
if self.s.held != 0 {
|
||||
self.rotate();
|
||||
}
|
||||
} else if p & SELECT != 0 {
|
||||
if self.s.held != 0 {
|
||||
self.flip(true);
|
||||
}
|
||||
} else if p & START != 0 && self.s.held != 0 {
|
||||
self.flip(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_up(&mut self) {
|
||||
let a = self.s.actors[CURSOR];
|
||||
let id = self.s.board[(cell_at(a.y, a.x) - MAP0) as usize];
|
||||
if id == 0 {
|
||||
return;
|
||||
}
|
||||
self.s.held = id;
|
||||
let slot = self.s.slots[slot(id)];
|
||||
(self.s.actors[CURSOR].y, self.s.actors[CURSOR].x) = (slot.y, slot.x);
|
||||
self.update_cursor_shape();
|
||||
for (c, under) in self.held_cells().iter().zip(slot.under) {
|
||||
self.s.board[(c.addr - MAP0) as usize] = 0;
|
||||
self.video.poke(c.addr, under);
|
||||
}
|
||||
self.play(SFX_PICK_UP);
|
||||
}
|
||||
|
||||
fn drop_held(&mut self) {
|
||||
let cells = self.held_cells();
|
||||
self.wait_frame();
|
||||
if self.cover(&cells) {
|
||||
self.play(SFX_ERROR);
|
||||
return;
|
||||
}
|
||||
self.put_down(&cells);
|
||||
// the cursor moves to the piece's last cell
|
||||
if let Some(last) = cells.last() {
|
||||
(self.s.actors[CURSOR].y, self.s.actors[CURSOR].x) = (last.y + 4, last.x + 4);
|
||||
}
|
||||
self.s.held = 0;
|
||||
self.update_cursor_shape();
|
||||
}
|
||||
|
||||
fn rotate(&mut self) {
|
||||
let held = self.s.held;
|
||||
self.s.held = (held & 0xFC) | (held.wrapping_add(1) & 3);
|
||||
self.update_cursor_shape();
|
||||
let a = self.s.actors[CURSOR];
|
||||
if self.out_of_bounds(a.shape, a.y, a.x) {
|
||||
self.s.held = held;
|
||||
} else {
|
||||
self.play(SFX_ROTATE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit 2 of the id mirrors the piece; bit 1 is adjusted so the mirror
|
||||
/// is about the expected axis. Select and Start mirror about different
|
||||
/// axes.
|
||||
fn flip(&mut self, select: bool) {
|
||||
let mut id = self.s.held ^ 4;
|
||||
if (id & 1 == 0) == select {
|
||||
id ^= 2;
|
||||
}
|
||||
self.s.held = id;
|
||||
self.update_cursor_shape();
|
||||
let a = self.s.actors[CURSOR];
|
||||
if !self.out_of_bounds(a.shape, a.y, a.x) {
|
||||
self.play(SFX_FLIP);
|
||||
return;
|
||||
}
|
||||
// (sic) undoing always uses Start's rule, so undoing a Select flip
|
||||
// can leave the piece turned
|
||||
id ^= 4;
|
||||
if id & 1 != 0 {
|
||||
id ^= 2;
|
||||
}
|
||||
self.s.held = id;
|
||||
}
|
||||
|
||||
/// Number of piece types whose cells all sit on the target area.
|
||||
pub(super) fn pieces_in_place(&self) -> u8 {
|
||||
self.s.slots[..13].iter().filter(|s| s.under[..5].iter().all(|&t| t >= TARGET_FLOOR)).count() as u8
|
||||
}
|
||||
|
||||
pub(super) fn level_clear(&mut self) {
|
||||
self.play(JINGLE_LEVEL_CLEAR);
|
||||
self.clear_actors();
|
||||
self.wait_frame();
|
||||
// map 1 keeps the finished board; the banner goes over map 0
|
||||
self.wipe_rows(MAP0, MAP1);
|
||||
let data = self.data.clone();
|
||||
for script in &data.scripts.level_clear_banner {
|
||||
self.video.draw(script);
|
||||
self.wait_frame();
|
||||
}
|
||||
let mut showing_board = false;
|
||||
let mut t: u8 = 0;
|
||||
loop {
|
||||
self.wait_frame();
|
||||
t = t.wrapping_add(1);
|
||||
if t & 0x1F == 0 {
|
||||
// sparkle the banner
|
||||
let frame = if t & 0x20 != 0 { 0x20 } else { 0 };
|
||||
self.video.load(0x8900, data.gfx(GFX_BANNER_SPARKLE + frame, 0x20));
|
||||
}
|
||||
if self.joy.pressed & START != 0 {
|
||||
return;
|
||||
}
|
||||
if self.joy.pressed & A != 0 {
|
||||
self.show(if showing_board { MAP0 } else { MAP1 });
|
||||
showing_board = !showing_board;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! The story screens shown before some levels, and the ending.
|
||||
|
||||
use super::*;
|
||||
use crate::input::*;
|
||||
use crate::rom::addr::*;
|
||||
use crate::rom::Text;
|
||||
use crate::sound::*;
|
||||
use crate::video::map_cell;
|
||||
|
||||
const TEXT_START: u16 = map_cell(MAP0, 2, 3);
|
||||
const ENDING_TEXT_START: u16 = map_cell(MAP0, 2, 1);
|
||||
const TEXT_ROWS: u16 = 8;
|
||||
const TEXT_WIDTH: u16 = 16;
|
||||
const BLANK: u8 = 0xFE;
|
||||
/// frames per letter
|
||||
const TYPE_DELAY: u8 = 8;
|
||||
|
||||
/// Next text position: one cell right (wrapping within the 256-byte page,
|
||||
/// as the original does).
|
||||
fn advance(at: u16) -> u16 {
|
||||
(at & 0xFF00) | (at as u8).wrapping_add(1) as u16
|
||||
}
|
||||
|
||||
/// Start of the line after next (text is double spaced).
|
||||
fn newline(at: u16) -> u16 {
|
||||
(at & 0xFFC0) + 0x62
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// Story backdrop for the current level, if it has one.
|
||||
pub(super) fn story_screen(&mut self) {
|
||||
self.s.story = self.data.story_for(self.level);
|
||||
if self.s.story == 0 {
|
||||
return;
|
||||
}
|
||||
let data = self.data.clone();
|
||||
self.video.load(0x8800, data.gfx(GFX_STORY, 0x800));
|
||||
self.video.load(0x9000, data.gfx(GFX_FONT, 0x800));
|
||||
self.video.fill_maps(BLANK);
|
||||
self.video.draw(&data.scripts.intermission);
|
||||
self.clear_actors();
|
||||
self.s.actors[HERO] = Actor::at(0x00, 0x50, 0x52); // the storyteller, shown while talking
|
||||
self.s.actors[MARKER] = Actor::at(0x80, 0x28, 0x50);
|
||||
self.set_palettes(PALETTE);
|
||||
self.play(MUSIC_MAP);
|
||||
self.show(MAP0);
|
||||
self.wait_frame();
|
||||
}
|
||||
|
||||
/// Type out the story a page at a time. A skips the rest of a page.
|
||||
pub(super) fn story_text(&mut self) {
|
||||
let story = self.s.story;
|
||||
if story == 0 || story > 4 {
|
||||
return;
|
||||
}
|
||||
let data = self.data.clone();
|
||||
let mut at = TEXT_START;
|
||||
for &t in &data.stories[story as usize - 1] {
|
||||
at = advance(at);
|
||||
match t {
|
||||
Text::End => {
|
||||
self.s.actors[HERO].y = 0;
|
||||
while self.joy.pressed & (A | START) == 0 {
|
||||
self.wait_frame();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Text::PageBreak => {
|
||||
self.s.actors[HERO].y = 0;
|
||||
while self.joy.pressed & A == 0 {
|
||||
self.wait_frame();
|
||||
}
|
||||
for row in 0..TEXT_ROWS {
|
||||
self.video.fill_row(TEXT_START + row * 32, BLANK, TEXT_WIDTH);
|
||||
self.wait_frame();
|
||||
}
|
||||
at = TEXT_START;
|
||||
}
|
||||
Text::Newline => at = newline(at),
|
||||
Text::Char(c) => {
|
||||
// while A is down (it stays "pressed" until the next
|
||||
// frame) letters are skipped without being drawn
|
||||
if self.joy.pressed & A != 0 {
|
||||
continue;
|
||||
}
|
||||
self.s.actors[HERO].y ^= 0x80; // mouth moves
|
||||
self.video.poke(at, c);
|
||||
for _ in 0..TYPE_DELAY {
|
||||
self.wait_frame();
|
||||
if self.joy.pressed & A != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ending(&mut self) {
|
||||
let data = self.data.clone();
|
||||
let gfx = |a, len| data.gfx(a, len);
|
||||
self.video.load(0x8000, gfx(GFX_STORY, 0x800));
|
||||
self.video.load(0x8800, gfx(GFX_STORY, 0x800));
|
||||
self.video.load(0x9000, gfx(GFX_FONT, 0x800));
|
||||
self.video.load(0x9600, gfx(GFX_ENDING_C, 0x140));
|
||||
self.video.load(0x8B00, gfx(GFX_ENDING_D, 0x100));
|
||||
self.video.load(0x8A40, gfx(GFX_ENDING_B, 0xC0));
|
||||
self.video.load(0x8C00, gfx(GFX_ENDING_A, 0x80));
|
||||
self.video.fill_maps(BLANK);
|
||||
self.video.draw(&data.scripts.ending);
|
||||
self.clear_actors();
|
||||
self.s.actors[HERO] = Actor::at(0xF0, 0x78, 0x5C);
|
||||
self.s.actors[MARKER] = Actor::at(0x60, 0x50, 0x5A);
|
||||
self.set_palettes(PALETTE);
|
||||
self.show(MAP0);
|
||||
self.play(MUSIC_TITLE);
|
||||
|
||||
let art = &data.scripts.ending_art;
|
||||
self.type_ending(&data.ending_text[0]);
|
||||
self.wait_for_button();
|
||||
self.draw_and_wait(&art[0]);
|
||||
self.draw_and_wait(&art[1]);
|
||||
self.open_door();
|
||||
self.wait_for_button();
|
||||
self.clear_ending_text();
|
||||
self.draw_and_wait(&art[2]);
|
||||
self.draw_and_wait(&art[3]);
|
||||
self.type_ending(&data.ending_text[1]);
|
||||
self.wait_for_button();
|
||||
self.clear_ending_text();
|
||||
self.type_ending(&data.ending_text[2]);
|
||||
self.draw_and_wait(&art[0]);
|
||||
self.draw_and_wait(&art[4]);
|
||||
self.wait_for_button();
|
||||
}
|
||||
|
||||
fn type_ending(&mut self, text: &[Text]) {
|
||||
let mut at = ENDING_TEXT_START;
|
||||
for &t in text {
|
||||
at = advance(at);
|
||||
let c = match t {
|
||||
Text::End => break,
|
||||
Text::Newline => {
|
||||
at = newline(at);
|
||||
continue;
|
||||
}
|
||||
Text::PageBreak => 0x81, // not a code in the ending's printer
|
||||
Text::Char(c) => c,
|
||||
};
|
||||
self.video.poke(at, c);
|
||||
self.s.actors[HERO].y ^= 0x80;
|
||||
self.wait_frames(TYPE_DELAY);
|
||||
}
|
||||
self.s.actors[HERO].y = 0xF0;
|
||||
}
|
||||
|
||||
fn wait_for_button(&mut self) {
|
||||
loop {
|
||||
self.wait_frame();
|
||||
if self.joy.pressed & (A | START) != 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_and_wait(&mut self, script: &crate::rom::DrawScript) {
|
||||
self.video.draw(script);
|
||||
self.wait_frame();
|
||||
}
|
||||
|
||||
fn open_door(&mut self) {
|
||||
self.wait_frames(12);
|
||||
for b in (1..=4).rev() {
|
||||
self.video.poke(map_cell(MAP0, 12, 9) - b, 0x68 - b as u8);
|
||||
self.wait_frames(12);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_ending_text(&mut self) {
|
||||
for row in 0..TEXT_ROWS {
|
||||
self.video.fill_row(ENDING_TEXT_START + row * 32, BLANK, TEXT_WIDTH);
|
||||
self.wait_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
//! Headless runner for testing: `opus --headless OUT_DIR SCRIPT...`
|
||||
//!
|
||||
//! Script tokens: `N` run N frames; `BUTTON:N` hold a button for N frames
|
||||
//! (up down left right a b start select); `shot:NAME` save OUT_DIR/NAME.png;
|
||||
//! `probe` print the level and cursor; `goto:YY:XX` steer the puzzle cursor
|
||||
//! (hex sprite coordinates). All audio is written to OUT_DIR/audio.wav.
|
||||
|
||||
use crate::game::Frame;
|
||||
use crate::input;
|
||||
use crate::rom::GameData;
|
||||
use crate::video::{SCREEN_H, SCREEN_W};
|
||||
use std::sync::Arc;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn write_png(path: &Path, w: usize, h: usize, gray: &[u8]) {
|
||||
let mut raw = Vec::new();
|
||||
for y in 0..h {
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(&gray[y * w..(y + 1) * w]);
|
||||
}
|
||||
// zlib stream with stored (uncompressed) deflate blocks
|
||||
let mut z = vec![0x78, 0x01];
|
||||
for (i, chunk) in raw.chunks(65535).enumerate() {
|
||||
let last = (i + 1) * 65535 >= raw.len();
|
||||
z.push(last as u8);
|
||||
z.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
|
||||
z.extend_from_slice(&(!(chunk.len() as u16)).to_le_bytes());
|
||||
z.extend_from_slice(chunk);
|
||||
}
|
||||
let (mut a, mut b) = (1u32, 0u32);
|
||||
for &x in &raw {
|
||||
a = (a + x as u32) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
z.extend_from_slice(&(b << 16 | a).to_be_bytes());
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let mut chunk = |t: &[u8], d: &[u8]| {
|
||||
png.extend_from_slice(&(d.len() as u32).to_be_bytes());
|
||||
let mut td = t.to_vec();
|
||||
td.extend_from_slice(d);
|
||||
png.extend_from_slice(&td);
|
||||
png.extend_from_slice(&crc32(&td).to_be_bytes());
|
||||
};
|
||||
let mut ihdr = Vec::new();
|
||||
ihdr.extend_from_slice(&(w as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&(h as u32).to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 0, 0, 0, 0]);
|
||||
chunk(b"IHDR", &ihdr);
|
||||
chunk(b"IDAT", &z);
|
||||
chunk(b"IEND", &[]);
|
||||
fs::write(path, png).unwrap();
|
||||
}
|
||||
|
||||
fn write_wav(path: &Path, rate: u32, samples: &[f32]) {
|
||||
let mut d = Vec::new();
|
||||
let data_len = samples.len() as u32 * 2;
|
||||
d.extend_from_slice(b"RIFF");
|
||||
d.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
d.extend_from_slice(b"WAVEfmt ");
|
||||
d.extend_from_slice(&16u32.to_le_bytes());
|
||||
d.extend_from_slice(&1u16.to_le_bytes());
|
||||
d.extend_from_slice(&2u16.to_le_bytes());
|
||||
d.extend_from_slice(&rate.to_le_bytes());
|
||||
d.extend_from_slice(&(rate * 4).to_le_bytes());
|
||||
d.extend_from_slice(&4u16.to_le_bytes());
|
||||
d.extend_from_slice(&16u16.to_le_bytes());
|
||||
d.extend_from_slice(b"data");
|
||||
d.extend_from_slice(&data_len.to_le_bytes());
|
||||
for s in samples {
|
||||
d.extend_from_slice(&((s.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes());
|
||||
}
|
||||
fs::write(path, d).unwrap();
|
||||
}
|
||||
|
||||
pub fn run(data: Arc<GameData>, args: &[String]) {
|
||||
let out = Path::new(&args[0]);
|
||||
fs::create_dir_all(out).unwrap();
|
||||
let rate = 44_100;
|
||||
let host = crate::start_game(data, rate);
|
||||
let mut audio = Vec::new();
|
||||
let mut frames = 0u64;
|
||||
let mut frame: Frame = host.frames.recv().expect("game thread");
|
||||
let mut step = |buttons: u8, n: u64, frame: &mut Frame| {
|
||||
for _ in 0..n {
|
||||
host.buttons.send(buttons).expect("game thread");
|
||||
*frame = host.frames.recv().expect("game thread");
|
||||
audio.extend_from_slice(&frame.audio);
|
||||
frames += 1;
|
||||
}
|
||||
};
|
||||
for tok in &args[1..] {
|
||||
if let Ok(n) = tok.parse::<u64>() {
|
||||
step(0, n, &mut frame);
|
||||
} else if let Some(name) = tok.strip_prefix("shot:") {
|
||||
let gray: Vec<u8> = frame.pixels.iter().map(|&s| [0xFF, 0xAA, 0x55, 0x00][s as usize]).collect();
|
||||
write_png(&out.join(format!("{name}.png")), SCREEN_W, SCREEN_H, &gray);
|
||||
println!("{name}.png: {:?}", frame.probe);
|
||||
} else if tok == "probe" {
|
||||
println!("{:?}", frame.probe);
|
||||
} else if let Some(spec) = tok.strip_prefix("goto:") {
|
||||
let (ty, tx) = spec.split_once(':').unwrap();
|
||||
let (ty, tx) = (u8::from_str_radix(ty, 16).unwrap(), u8::from_str_radix(tx, 16).unwrap());
|
||||
for _ in 0..100 {
|
||||
let p = frame.probe;
|
||||
let bit = if p.cursor_y < ty { input::DOWN } else if p.cursor_y > ty { input::UP }
|
||||
else if p.cursor_x < tx { input::RIGHT } else if p.cursor_x > tx { input::LEFT } else { break };
|
||||
step(bit, 1, &mut frame);
|
||||
step(0, 5, &mut frame);
|
||||
}
|
||||
} else if let Some((btn, n)) = tok.split_once(':') {
|
||||
let bit = match btn {
|
||||
"up" => input::UP, "down" => input::DOWN, "left" => input::LEFT, "right" => input::RIGHT,
|
||||
"a" => input::A, "b" => input::B, "start" => input::START, "select" => input::SELECT,
|
||||
_ => panic!("unknown token {tok}"),
|
||||
};
|
||||
step(bit, n.parse().unwrap(), &mut frame);
|
||||
} else {
|
||||
panic!("unknown token {tok}");
|
||||
}
|
||||
}
|
||||
drop(step);
|
||||
write_wav(&out.join("audio.wav"), rate, &audio);
|
||||
println!("{frames} frames, {:.1}s of audio", audio.len() as f32 / 2.0 / rate as f32);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Buttons, as the game reads them once per frame.
|
||||
|
||||
pub const A: u8 = 0x01;
|
||||
pub const B: u8 = 0x02;
|
||||
pub const SELECT: u8 = 0x04;
|
||||
pub const START: u8 = 0x08;
|
||||
pub const RIGHT: u8 = 0x10;
|
||||
pub const LEFT: u8 = 0x20;
|
||||
pub const UP: u8 = 0x40;
|
||||
pub const DOWN: u8 = 0x80;
|
||||
pub const DPAD: u8 = 0xF0;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Joypad {
|
||||
/// buttons down this frame
|
||||
pub held: u8,
|
||||
/// buttons that went down this frame
|
||||
pub pressed: u8,
|
||||
/// d-pad with auto-repeat: fires on press, then after 10 frames, then
|
||||
/// every 4 frames while held
|
||||
pub repeat: u8,
|
||||
repeat_timer: u8,
|
||||
}
|
||||
|
||||
impl Joypad {
|
||||
pub fn update(&mut self, now: u8) {
|
||||
self.pressed = (self.held ^ now) & now;
|
||||
self.held = now;
|
||||
self.repeat = 0;
|
||||
if now == 0 {
|
||||
return;
|
||||
}
|
||||
if self.pressed & DPAD != 0 {
|
||||
self.repeat = self.pressed & DPAD;
|
||||
self.repeat_timer = 10;
|
||||
return;
|
||||
}
|
||||
self.repeat_timer = self.repeat_timer.wrapping_sub(1);
|
||||
if self.repeat_timer == 0 {
|
||||
self.repeat_timer = 4;
|
||||
self.repeat = now & DPAD;
|
||||
}
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
//! Daedalian Opus, ported to Rust.
|
||||
//!
|
||||
//! Usage: `opus [--rom PATH] [--headless OUT_DIR SCRIPT...]`
|
||||
//!
|
||||
//! The game's data is read from the original ROM ("Daedalian Opus (USA).gb"),
|
||||
//! looked for in the current directory and its parent unless given.
|
||||
//!
|
||||
//! Controls: arrows = D-pad, Z = A, X = B, Enter = Start,
|
||||
//! Right Shift / Backspace = Select, Esc = quit.
|
||||
|
||||
mod apu;
|
||||
mod game;
|
||||
mod headless;
|
||||
mod input;
|
||||
mod rom;
|
||||
mod sound;
|
||||
mod video;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use pixels::{Pixels, SurfaceTexture};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::LogicalSize;
|
||||
use winit::event::{ElementState, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
use game::{Frame, FRAME_HZ};
|
||||
use video::{SCREEN_H, SCREEN_W};
|
||||
|
||||
const ROM_NAME: &str = "Daedalian Opus (USA).gb";
|
||||
const SHADES: [[u8; 4]; 4] = [
|
||||
[0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0xAA, 0xAA, 0xAA, 0xFF],
|
||||
[0x55, 0x55, 0x55, 0xFF],
|
||||
[0x00, 0x00, 0x00, 0xFF],
|
||||
];
|
||||
|
||||
type AudioQueue = Arc<Mutex<VecDeque<f32>>>;
|
||||
|
||||
/// The host's end of the connection to the game thread.
|
||||
pub struct Host {
|
||||
pub frames: Receiver<Frame>,
|
||||
pub buttons: SyncSender<u8>,
|
||||
}
|
||||
|
||||
pub fn start_game(data: Arc<rom::GameData>, sample_rate: u32) -> Host {
|
||||
let (frame_tx, frame_rx) = sync_channel(1);
|
||||
let (button_tx, button_rx) = sync_channel(1);
|
||||
game::spawn(data, sample_rate, game::Link { frames: frame_tx, buttons: button_rx });
|
||||
Host { frames: frame_rx, buttons: button_tx }
|
||||
}
|
||||
|
||||
struct App {
|
||||
window: Option<Arc<Window>>,
|
||||
pixels: Option<Pixels<'static>>,
|
||||
host: Host,
|
||||
buttons: u8,
|
||||
screen: Vec<u8>,
|
||||
next_frame: Instant,
|
||||
frame_time: Duration,
|
||||
audio: AudioQueue,
|
||||
audio_rate: u32,
|
||||
_stream: Option<cpal::Stream>,
|
||||
}
|
||||
|
||||
fn start_audio(queue: AudioQueue) -> Option<(cpal::Stream, u32)> {
|
||||
let device = cpal::default_host().default_output_device()?;
|
||||
let config = device.default_output_config().ok()?;
|
||||
if config.sample_format() != cpal::SampleFormat::F32 {
|
||||
eprintln!("audio: unsupported sample format {:?}, running silent", config.sample_format());
|
||||
return None;
|
||||
}
|
||||
let rate = config.sample_rate();
|
||||
let channels = config.channels() as usize;
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
config.config(),
|
||||
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||
let mut q = queue.lock().unwrap();
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let l = q.pop_front().unwrap_or(0.0);
|
||||
let r = q.pop_front().unwrap_or(l);
|
||||
for (i, s) in frame.iter_mut().enumerate() {
|
||||
*s = if i % 2 == 0 { l } else { r };
|
||||
}
|
||||
}
|
||||
},
|
||||
|e| eprintln!("audio error: {e}"),
|
||||
None,
|
||||
)
|
||||
.ok()?;
|
||||
stream.play().ok()?;
|
||||
Some((stream, rate))
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn key(&mut self, code: KeyCode, pressed: bool) {
|
||||
let bit = match code {
|
||||
KeyCode::ArrowRight => input::RIGHT,
|
||||
KeyCode::ArrowLeft => input::LEFT,
|
||||
KeyCode::ArrowUp => input::UP,
|
||||
KeyCode::ArrowDown => input::DOWN,
|
||||
KeyCode::KeyZ => input::A,
|
||||
KeyCode::KeyX => input::B,
|
||||
KeyCode::ShiftRight | KeyCode::Backspace => input::SELECT,
|
||||
KeyCode::Enter => input::START,
|
||||
_ => return,
|
||||
};
|
||||
if pressed {
|
||||
self.buttons |= bit;
|
||||
} else {
|
||||
self.buttons &= !bit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the game one frame. False if the game thread has stopped.
|
||||
fn step(&mut self) -> bool {
|
||||
let Ok(frame) = self.host.frames.recv() else { return false };
|
||||
self.screen = frame.pixels;
|
||||
let mut q = self.audio.lock().unwrap();
|
||||
q.extend(frame.audio);
|
||||
let max = self.audio_rate as usize / 5 * 2; // keep at most ~200 ms queued
|
||||
if q.len() > max {
|
||||
let excess = q.len() - max;
|
||||
q.drain(..excess);
|
||||
}
|
||||
drop(q);
|
||||
self.host.buttons.send(self.buttons).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attrs = Window::default_attributes()
|
||||
.with_title("Daedalian Opus")
|
||||
.with_inner_size(LogicalSize::new(SCREEN_W as f64 * 4.0, SCREEN_H as f64 * 4.0));
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
|
||||
let size = window.inner_size();
|
||||
let surface = SurfaceTexture::new(size.width, size.height, window.clone());
|
||||
let pixels = Pixels::new(SCREEN_W as u32, SCREEN_H as u32, surface).expect("pixels");
|
||||
self.window = Some(window);
|
||||
self.pixels = Some(pixels);
|
||||
self.next_frame = Instant::now();
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
if code == KeyCode::Escape {
|
||||
event_loop.exit();
|
||||
}
|
||||
self.key(code, event.state == ElementState::Pressed);
|
||||
}
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(p) = &mut self.pixels {
|
||||
let _ = p.resize_surface(size.width.max(1), size.height.max(1));
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
if let Some(p) = &mut self.pixels {
|
||||
for (dst, &shade) in p.frame_mut().chunks_exact_mut(4).zip(&self.screen) {
|
||||
dst.copy_from_slice(&SHADES[shade as usize]);
|
||||
}
|
||||
if let Err(e) = p.render() {
|
||||
eprintln!("render error: {e}");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut ran = 0;
|
||||
while self.next_frame <= now && ran < 4 {
|
||||
if !self.step() {
|
||||
eprintln!("the game stopped");
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.next_frame += self.frame_time;
|
||||
ran += 1;
|
||||
}
|
||||
if self.next_frame < now {
|
||||
self.next_frame = now; // fell behind; don't try to catch up forever
|
||||
}
|
||||
if let Some(w) = &self.window {
|
||||
w.request_redraw();
|
||||
}
|
||||
event_loop.set_control_flow(ControlFlow::WaitUntil(self.next_frame));
|
||||
}
|
||||
}
|
||||
|
||||
fn find_rom(explicit: Option<String>) -> Option<PathBuf> {
|
||||
if let Some(p) = explicit {
|
||||
return Some(p.into());
|
||||
}
|
||||
[PathBuf::from(ROM_NAME), PathBuf::from("..").join(ROM_NAME)].into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut rom_path = None;
|
||||
if let Some(i) = args.iter().position(|a| a == "--rom") {
|
||||
if i + 1 < args.len() {
|
||||
rom_path = Some(args.remove(i + 1));
|
||||
}
|
||||
args.remove(i);
|
||||
}
|
||||
let Some(rom_path) = find_rom(rom_path) else {
|
||||
eprintln!("can't find \"{ROM_NAME}\"; pass its path with --rom");
|
||||
std::process::exit(1);
|
||||
};
|
||||
let rom = match rom::Rom::load(&rom_path) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let data = Arc::new(rom::GameData::parse(rom));
|
||||
|
||||
if args.first().map(|s| s.as_str()) == Some("--headless") {
|
||||
headless::run(data, &args[1..]);
|
||||
return;
|
||||
}
|
||||
|
||||
let audio: AudioQueue = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let (stream, rate) = match start_audio(audio.clone()) {
|
||||
Some((s, r)) => (Some(s), r),
|
||||
None => {
|
||||
eprintln!("audio: no output device, running silent");
|
||||
(None, 48_000)
|
||||
}
|
||||
};
|
||||
let mut app = App {
|
||||
window: None,
|
||||
pixels: None,
|
||||
host: start_game(data, rate),
|
||||
buttons: 0,
|
||||
screen: vec![0; SCREEN_W * SCREEN_H],
|
||||
next_frame: Instant::now(),
|
||||
frame_time: Duration::from_secs_f64(1.0 / FRAME_HZ),
|
||||
audio,
|
||||
audio_rate: rate,
|
||||
_stream: stream,
|
||||
};
|
||||
let event_loop = EventLoop::new().expect("event loop");
|
||||
event_loop.run_app(&mut app).expect("run");
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
//! The game's data, parsed out of the original ROM at startup.
|
||||
//!
|
||||
//! Nothing copyrighted is compiled into this program: levels, piece shapes,
|
||||
//! screen layouts, story text, graphics and music all come from the .gb file
|
||||
//! the player supplies. This module turns the raw bytes into typed structures;
|
||||
//! the addresses below are where each table lives in the ROM.
|
||||
|
||||
/// Where things live in the ROM.
|
||||
pub mod addr {
|
||||
// ---- levels ----
|
||||
pub const LEVEL_PIECES: u16 = 0x0210; // 36 pointers to [id, y, x]* 0-terminated lists
|
||||
pub const PIECE_SHAPE_INDEX: u16 = 0x07FA; // (type-1)*8 + orientation -> shape number
|
||||
pub const LEVEL_STORY: u16 = 0x0EA5; // story number shown before each level (0 = none)
|
||||
pub const LEVEL_BOARDS: u16 = 0x19E4; // 36 pointers to board draw scripts
|
||||
pub const LEVEL_NEW_PIECE: u16 = 0x255B; // piece that drops from the sky on the map
|
||||
pub const SHAPE_PIECE_ID: u16 = 0x264B; // shape number -> piece id (inverse of the above)
|
||||
pub const LEVEL_PIECES_NEEDED: u16 = 0x2771;
|
||||
pub const LEVEL_MUSIC: u16 = 0x27A0;
|
||||
pub const LEVEL_CURSOR_Y: u16 = 0x27F3;
|
||||
pub const LEVEL_CURSOR_X: u16 = 0x2817;
|
||||
pub const PASSWORDS: u16 = 0x3433; // 36 x 4 ASCII letters
|
||||
pub const SHAPES: u16 = 0x350B; // pointers to [attr|count, (yx, tile)*count]
|
||||
pub const SHAPE_COUNT: usize = 0x5E;
|
||||
|
||||
// ---- screen layouts (tilemap draw scripts) ----
|
||||
pub const ENDING_SCREEN: u16 = 0x0ABB;
|
||||
pub const ENDING_ART: [u16; 5] = [0x0B7C, 0x0B8E, 0x0BAB, 0x0BBD, 0x0BDA];
|
||||
pub const LEVEL_CLEAR_BANNER: u16 = 0x0D46; // 5-byte scripts, drawn one per frame
|
||||
pub const INTERMISSION_SCREEN: u16 = 0x0ED6;
|
||||
pub const HOW_TO_PLAY_SCREEN: u16 = 0x0FE2;
|
||||
pub const MAP_SCREEN: u16 = 0x12D4;
|
||||
pub const MAP_SCENES: u16 = 0x1303; // 3 pointers: first level, middle, last level
|
||||
pub const MAP_CASTLE_INDEX: u16 = 0x15C7;
|
||||
pub const MAP_CASTLES: u16 = 0x15EB; // 5 pointers
|
||||
pub const MAP_NEXT_CASTLE_INDEX: u16 = 0x1692;
|
||||
pub const MAP_NEXT_CASTLES: u16 = 0x16B6; // 4 pointers
|
||||
pub const PUZZLE_LIST_SCREEN: u16 = 0x18BE;
|
||||
pub const PUZZLE_HUD: u16 = 0x27C4;
|
||||
pub const TITLE_SCREEN: u16 = 0x2F2D;
|
||||
pub const TITLE_SCREEN_ALT: u16 = 0x2FFE; // drawn over a copy of the title in map 1
|
||||
pub const PASSWORD_SCREEN: u16 = 0x33DF;
|
||||
|
||||
// ---- text ----
|
||||
pub const STORIES: u16 = 0x2A5A; // 4 pointers
|
||||
pub const ENDING_TEXT: [u16; 3] = [0x0BEF, 0x0C22, 0x0C51];
|
||||
|
||||
// ---- graphics (uncompressed 2bpp tiles) ----
|
||||
pub const GFX_MAP_TERRAIN: u16 = 0x5000;
|
||||
pub const GFX_ENDING_A: u16 = 0x5010;
|
||||
pub const GFX_ENDING_B: u16 = 0x5340;
|
||||
pub const GFX_STORY: u16 = 0x5800;
|
||||
pub const GFX_SPRITES: u16 = 0x6000; // sprites, then puzzle tiles from $6800
|
||||
pub const GFX_ENDING_C: u16 = 0x6200;
|
||||
pub const GFX_BANNER_SPARKLE: u16 = 0x6900; // two 2-tile frames
|
||||
pub const GFX_HOW_TO_PLAY: u16 = 0x7000;
|
||||
pub const GFX_ENDING_D: u16 = 0x7300;
|
||||
pub const GFX_FONT: u16 = 0x7800;
|
||||
|
||||
// ---- sound ----
|
||||
pub const SOUND_HEADERS: u16 = 0x39FA; // indexed by (sound id - $80) * 2
|
||||
pub const SILENCE_HEADER: u16 = 0x3BF0;
|
||||
pub const VIBRATO_TABLES: u16 = 0x40E5; // indexed by vibrato number * 2 (number >= 1)
|
||||
pub const WAVEFORMS: u16 = 0x4157;
|
||||
pub const NOTE_PERIODS: u16 = 0x419F;
|
||||
}
|
||||
|
||||
/// The raw ROM image.
|
||||
pub struct Rom {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Rom {
|
||||
pub fn load(path: &std::path::Path) -> Result<Rom, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if bytes.len() != 0x8000 || &bytes[0x134..0x142] != b"DAEDALIAN OPUS" {
|
||||
return Err(format!("{} is not a Daedalian Opus (USA) ROM", path.display()));
|
||||
}
|
||||
Ok(Rom { bytes })
|
||||
}
|
||||
|
||||
pub fn byte(&self, addr: u16) -> u8 {
|
||||
self.bytes[addr as usize]
|
||||
}
|
||||
|
||||
pub fn word(&self, addr: u16) -> u16 {
|
||||
u16::from_le_bytes([self.byte(addr), self.byte(addr.wrapping_add(1))])
|
||||
}
|
||||
|
||||
pub fn bytes(&self, addr: u16, len: usize) -> &[u8] {
|
||||
&self.bytes[addr as usize..addr as usize + len]
|
||||
}
|
||||
|
||||
fn table(&self, addr: u16, len: usize) -> Vec<u8> {
|
||||
self.bytes(addr, len).to_vec()
|
||||
}
|
||||
|
||||
fn pointers(&self, addr: u16, count: usize) -> Vec<u16> {
|
||||
(0..count).map(|i| self.word(addr + 2 * i as u16)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tilemap draw script: the game's format for screen layouts. Each command
|
||||
/// writes a run of bytes into VRAM, either along a row or down a column.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DrawScript(pub Vec<DrawCmd>);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DrawCmd {
|
||||
pub addr: u16,
|
||||
pub vertical: bool,
|
||||
pub tiles: Tiles,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Tiles {
|
||||
/// the same tile repeated
|
||||
Run(u8, usize),
|
||||
/// one tile per position
|
||||
List(Vec<u8>),
|
||||
}
|
||||
|
||||
impl DrawScript {
|
||||
/// Command byte: bit 6 = vertical, bit 5 = repeat one tile, the rest
|
||||
/// ($9F mask) is the address high byte. Then address low, count, data.
|
||||
/// A zero command byte ends the script.
|
||||
pub fn parse(rom: &Rom, mut a: u16) -> DrawScript {
|
||||
let mut cmds = Vec::new();
|
||||
loop {
|
||||
let c = rom.byte(a);
|
||||
if c == 0 {
|
||||
return DrawScript(cmds);
|
||||
}
|
||||
let addr = u16::from(c & 0x9F) << 8 | u16::from(rom.byte(a + 1));
|
||||
let count = match rom.byte(a + 2) {
|
||||
0 => 256,
|
||||
n => n as usize,
|
||||
};
|
||||
a += 3;
|
||||
let tiles = if c & 0x20 != 0 {
|
||||
a += 1;
|
||||
Tiles::Run(rom.byte(a - 1), count)
|
||||
} else {
|
||||
a += count as u16;
|
||||
Tiles::List(rom.bytes(a - count as u16, count).to_vec())
|
||||
};
|
||||
cmds.push(DrawCmd { addr, vertical: c & 0x40 != 0, tiles });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of a shape: packed y/x offsets in 8-pixel units, and a tile.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ShapeCell {
|
||||
pub yx: u8,
|
||||
pub tile: u8,
|
||||
}
|
||||
|
||||
impl ShapeCell {
|
||||
/// Offset used when stamping a piece onto the board (unsigned nibbles).
|
||||
pub fn board_offset(self) -> (u8, u8) {
|
||||
((self.yx >> 4) * 8, (self.yx & 0x0F) * 8)
|
||||
}
|
||||
|
||||
/// Offset used when drawing the shape as sprites (signed nibbles).
|
||||
pub fn sprite_offset(self) -> (u8, u8) {
|
||||
let dy = ((self.yx & 0xF0) as i8) >> 1;
|
||||
let dx = (((self.yx << 4) & 0xF0) as i8) >> 1;
|
||||
(dy as u8, dx as u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape: the cells of a puzzle piece in one orientation, or a multi-tile
|
||||
/// sprite (cursor, hero, messenger...). Pieces and sprites share the format.
|
||||
#[derive(Clone)]
|
||||
pub struct Shape {
|
||||
/// OAM attribute bits used when drawn as sprites (palette, flips)
|
||||
pub attr: u8,
|
||||
pub cells: Vec<ShapeCell>,
|
||||
}
|
||||
|
||||
/// A piece as it sits on the board when a level starts.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct StartPiece {
|
||||
pub id: u8,
|
||||
pub y: u8,
|
||||
pub x: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Level {
|
||||
pub pieces: Vec<StartPiece>,
|
||||
pub board: DrawScript,
|
||||
pub pieces_needed: u8,
|
||||
pub music: u8,
|
||||
/// cursor start position, sprite coordinates
|
||||
pub cursor: (u8, u8),
|
||||
/// piece id that falls from the sky on the map screen (0 = none)
|
||||
pub new_piece: u8,
|
||||
/// story shown before the level (0 = none, 1-4)
|
||||
pub story: u8,
|
||||
pub map_castle: u8,
|
||||
pub map_next_castle: u8,
|
||||
}
|
||||
|
||||
/// A piece of story text, as tile numbers plus layout codes.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Text {
|
||||
Char(u8),
|
||||
Newline,
|
||||
PageBreak,
|
||||
End,
|
||||
}
|
||||
|
||||
fn parse_text(rom: &Rom, mut a: u16) -> Vec<Text> {
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
let t = match rom.byte(a) {
|
||||
0x80 => Text::Newline,
|
||||
0x81 => Text::PageBreak,
|
||||
0x82 => Text::End,
|
||||
c => Text::Char(c),
|
||||
};
|
||||
out.push(t);
|
||||
a += 1;
|
||||
if t == Text::End {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Scripts {
|
||||
pub title: DrawScript,
|
||||
pub title_alt: DrawScript,
|
||||
pub password: DrawScript,
|
||||
pub puzzle_list: DrawScript,
|
||||
pub how_to_play: DrawScript,
|
||||
pub intermission: DrawScript,
|
||||
pub ending: DrawScript,
|
||||
pub ending_art: Vec<DrawScript>,
|
||||
pub puzzle_hud: DrawScript,
|
||||
pub level_clear_banner: Vec<DrawScript>,
|
||||
pub map: DrawScript,
|
||||
pub map_scenes: Vec<DrawScript>,
|
||||
pub map_castles: Vec<DrawScript>,
|
||||
pub map_next_castles: Vec<DrawScript>,
|
||||
}
|
||||
|
||||
/// Everything the game needs, parsed from the ROM.
|
||||
pub struct GameData {
|
||||
pub rom: Rom,
|
||||
pub levels: Vec<Level>,
|
||||
pub shapes: Vec<Shape>,
|
||||
piece_shape_index: Vec<u8>,
|
||||
shape_piece_id: Vec<u8>,
|
||||
/// 36 four-letter ASCII passwords; entry n starts level n+1 (0-based),
|
||||
/// and the last one opens the puzzle list.
|
||||
pub passwords: Vec<[u8; 4]>,
|
||||
pub stories: Vec<Vec<Text>>,
|
||||
pub ending_text: Vec<Vec<Text>>,
|
||||
pub scripts: Scripts,
|
||||
}
|
||||
|
||||
pub const LEVEL_COUNT: usize = 36;
|
||||
|
||||
impl GameData {
|
||||
pub fn parse(rom: Rom) -> GameData {
|
||||
let script = |a| DrawScript::parse(&rom, a);
|
||||
let piece_lists = rom.pointers(addr::LEVEL_PIECES, LEVEL_COUNT);
|
||||
let boards = rom.pointers(addr::LEVEL_BOARDS, LEVEL_COUNT);
|
||||
let levels = (0..LEVEL_COUNT)
|
||||
.map(|n| {
|
||||
let mut pieces = Vec::new();
|
||||
let mut a = piece_lists[n];
|
||||
while rom.byte(a) != 0 {
|
||||
pieces.push(StartPiece { id: rom.byte(a), y: rom.byte(a + 1), x: rom.byte(a + 2) });
|
||||
a += 3;
|
||||
}
|
||||
let n16 = n as u16;
|
||||
Level {
|
||||
pieces,
|
||||
board: script(boards[n]),
|
||||
pieces_needed: rom.byte(addr::LEVEL_PIECES_NEEDED + n16),
|
||||
music: rom.byte(addr::LEVEL_MUSIC + n16),
|
||||
cursor: (rom.byte(addr::LEVEL_CURSOR_Y + n16), rom.byte(addr::LEVEL_CURSOR_X + n16)),
|
||||
new_piece: rom.byte(addr::LEVEL_NEW_PIECE + n16),
|
||||
story: rom.byte(addr::LEVEL_STORY + n16),
|
||||
map_castle: rom.byte(addr::MAP_CASTLE_INDEX + n16),
|
||||
map_next_castle: rom.byte(addr::MAP_NEXT_CASTLE_INDEX + n16),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let shapes = rom
|
||||
.pointers(addr::SHAPES, addr::SHAPE_COUNT)
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let head = rom.byte(a);
|
||||
let cells = (0..(head & 0x0F) as u16)
|
||||
.map(|i| ShapeCell { yx: rom.byte(a + 1 + 2 * i), tile: rom.byte(a + 2 + 2 * i) })
|
||||
.collect();
|
||||
Shape { attr: head & 0xF0, cells }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let passwords = (0..LEVEL_COUNT as u16)
|
||||
.map(|i| rom.bytes(addr::PASSWORDS + 4 * i, 4).try_into().unwrap())
|
||||
.collect();
|
||||
|
||||
let scripts = Scripts {
|
||||
title: script(addr::TITLE_SCREEN),
|
||||
title_alt: script(addr::TITLE_SCREEN_ALT),
|
||||
password: script(addr::PASSWORD_SCREEN),
|
||||
puzzle_list: script(addr::PUZZLE_LIST_SCREEN),
|
||||
how_to_play: script(addr::HOW_TO_PLAY_SCREEN),
|
||||
intermission: script(addr::INTERMISSION_SCREEN),
|
||||
ending: script(addr::ENDING_SCREEN),
|
||||
ending_art: addr::ENDING_ART.iter().map(|&a| script(a)).collect(),
|
||||
puzzle_hud: script(addr::PUZZLE_HUD),
|
||||
level_clear_banner: {
|
||||
let mut v = Vec::new();
|
||||
let mut a = addr::LEVEL_CLEAR_BANNER;
|
||||
while rom.byte(a) != 0 {
|
||||
v.push(script(a));
|
||||
a += 5;
|
||||
}
|
||||
v
|
||||
},
|
||||
map: script(addr::MAP_SCREEN),
|
||||
map_scenes: rom.pointers(addr::MAP_SCENES, 3).into_iter().map(script).collect(),
|
||||
map_castles: rom.pointers(addr::MAP_CASTLES, 5).into_iter().map(script).collect(),
|
||||
map_next_castles: rom.pointers(addr::MAP_NEXT_CASTLES, 4).into_iter().map(script).collect(),
|
||||
};
|
||||
|
||||
GameData {
|
||||
levels,
|
||||
shapes,
|
||||
piece_shape_index: rom.table(addr::PIECE_SHAPE_INDEX, 0x80),
|
||||
shape_piece_id: rom.table(addr::SHAPE_PIECE_ID, 0x41),
|
||||
passwords,
|
||||
stories: rom.pointers(addr::STORIES, 4).into_iter().map(|a| parse_text(&rom, a)).collect(),
|
||||
ending_text: addr::ENDING_TEXT.iter().map(|&a| parse_text(&rom, a)).collect(),
|
||||
scripts,
|
||||
rom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Piece id is `type << 4 | orientation`: bits 0-1 rotate, bit 2 flips.
|
||||
pub fn piece_shape(&self, piece_id: u8) -> u8 {
|
||||
let d = piece_id.wrapping_sub(0x10);
|
||||
self.piece_shape_index[((d & 0xF0) >> 1 | (d & 0x07)) as usize]
|
||||
}
|
||||
|
||||
/// The piece id a placed shape is recorded under on the board.
|
||||
pub fn shape_piece_id(&self, shape: u8) -> u8 {
|
||||
self.shape_piece_id.get(shape as usize).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Story number for a level index (index 36 exists in the table too).
|
||||
pub fn story_for(&self, level: u8) -> u8 {
|
||||
self.rom.byte(addr::LEVEL_STORY + level as u16)
|
||||
}
|
||||
|
||||
/// Raw tile graphics.
|
||||
pub fn gfx(&self, addr: u16, len: usize) -> &[u8] {
|
||||
self.rom.bytes(addr, len)
|
||||
}
|
||||
}
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
//! The game's sound driver: a byte-code music sequencer with 8 channels
|
||||
//! (4 for music, 4 for sound effects that temporarily take over the music's
|
||||
//! hardware voices). Runs once per frame and plays the sound chip in `apu.rs`.
|
||||
//!
|
||||
//! Songs and effects are byte code in the ROM. A sound's header is a channel
|
||||
//! count followed by 8 bytes per channel: flags, voice (0-1 pulse, 2 wave,
|
||||
//! 3 noise), stereo mask, start address, transpose, vibrato, envelope.
|
||||
//! Channel byte code:
|
||||
//!
|
||||
//! | byte | meaning |
|
||||
//! |-----------|------------------------------------------------------------|
|
||||
//! | $00-$7F | set note length (frames) and replay the current note |
|
||||
//! | $80 | rest |
|
||||
//! | $81-$DF | note (+ transpose); then an optional slide step if sliding, |
|
||||
//! | | then an optional length byte |
|
||||
//! | $E0/$E1 a | call subroutine at a |
|
||||
//! | $E2 | return |
|
||||
//! | $E3 | end of song |
|
||||
//! | $E4 i n a | loop: counter i starts at n; jump to a until it runs out |
|
||||
//! | $E5 a | jump |
|
||||
//! | $E6 v | envelope | $E7 t | transpose += t |
|
||||
//! | $E8 d | duty cycle | $E9 m | stereo mask |
|
||||
//! | $EA w | load waveform w | $EB | end of sound effect |
|
||||
//! | $EC / $ED | pitch slide on/off | $EE | tie (don't retrigger) |
|
||||
//! | $EF v | master volume | $F0 v | vibrato table v |
|
||||
//! | $F1 p | set period low byte directly |
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::apu::*;
|
||||
use crate::rom::{addr, GameData};
|
||||
|
||||
// ---- sound ids ----
|
||||
pub const MUSIC_TITLE: u8 = 0x81;
|
||||
pub const MUSIC_MAP: u8 = 0x83; // also the story and how-to-play screens
|
||||
pub const JINGLE_LEVEL_CLEAR: u8 = 0x86;
|
||||
pub const SFX_CONFIRM: u8 = 0x87;
|
||||
pub const SFX_MENU_MOVE: u8 = 0x88;
|
||||
pub const SFX_ROTATE: u8 = 0x89; // also: puzzle reset
|
||||
pub const SFX_PASSWORD_LETTER: u8 = 0x8A;
|
||||
pub const SFX_FLIP: u8 = 0x8B;
|
||||
pub const SFX_PLACE: u8 = 0x8C;
|
||||
pub const SFX_BRIDGE: u8 = 0x8D;
|
||||
pub const SFX_PICK_UP: u8 = 0x8E;
|
||||
pub const SFX_ERROR: u8 = 0x8F;
|
||||
pub const SFX_STEP: u8 = 0x90;
|
||||
pub const SFX_MOVE_PIECE: u8 = 0x91;
|
||||
pub const SFX_PIECE_LANDS: u8 = 0x92;
|
||||
pub const PAUSE_MUSIC: u8 = 0x95;
|
||||
pub const RESUME_MUSIC: u8 = 0x96;
|
||||
|
||||
/// "no sound waiting to start"
|
||||
const IDLE: u8 = 0x80;
|
||||
|
||||
// channel flag bits
|
||||
const ACTIVE: u8 = 0x80;
|
||||
const SLIDING: u8 = 0x20;
|
||||
const PAUSED: u8 = 0x10;
|
||||
/// a sound effect is using this music channel's voice
|
||||
const OVERRIDDEN: u8 = 0x04;
|
||||
/// the current note should restart its envelope
|
||||
const RETRIGGER: u8 = 0x01;
|
||||
|
||||
const PULSE1: u8 = 0;
|
||||
const PULSE2: u8 = 1;
|
||||
const WAVE: u8 = 2;
|
||||
const NOISE: u8 = 3;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Channel {
|
||||
flags: u8,
|
||||
voice: u8,
|
||||
stereo: u8,
|
||||
pc: u16,
|
||||
transpose: u8,
|
||||
vibrato: u8,
|
||||
envelope: u8,
|
||||
return_stack: Vec<u16>,
|
||||
length: u8,
|
||||
timer: u8,
|
||||
vibrato_pos: u8,
|
||||
/// period of the current note (0 = rest)
|
||||
base: u16,
|
||||
/// slid period
|
||||
slide: u16,
|
||||
/// period actually played this frame
|
||||
period: u16,
|
||||
slide_step: u8,
|
||||
duty: u8,
|
||||
loop_counters: [u8; 11],
|
||||
}
|
||||
|
||||
/// Last values written to the chip, so unchanged values aren't rewritten
|
||||
/// (rewriting a period would restart the note).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Written {
|
||||
env: [u8; 4],
|
||||
lo: [u8; 4],
|
||||
hi: [u8; 4],
|
||||
}
|
||||
|
||||
pub struct SoundDriver {
|
||||
data: Arc<GameData>,
|
||||
queue: VecDeque<u8>,
|
||||
pending: u8,
|
||||
music_priority: u8,
|
||||
sfx_priority: u8,
|
||||
music_wave: u8,
|
||||
sfx_wave: u8,
|
||||
loaded_wave: u8,
|
||||
master_volume: u8,
|
||||
/// 0-3 music, 4-7 sound effects
|
||||
channels: [Channel; 8],
|
||||
written: Written,
|
||||
}
|
||||
|
||||
impl SoundDriver {
|
||||
pub fn new(data: Arc<GameData>) -> SoundDriver {
|
||||
SoundDriver {
|
||||
data,
|
||||
queue: VecDeque::new(),
|
||||
pending: 0,
|
||||
music_priority: 0,
|
||||
sfx_priority: 0,
|
||||
music_wave: 0,
|
||||
sfx_wave: 0,
|
||||
loaded_wave: 0,
|
||||
master_volume: 0,
|
||||
channels: Default::default(),
|
||||
written: Written::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a song or sound effect; one queued sound starts per frame.
|
||||
pub fn play(&mut self, id: u8) {
|
||||
self.queue.push_back(id);
|
||||
}
|
||||
|
||||
fn rom(&self, a: u16) -> u8 {
|
||||
self.data.rom.byte(a)
|
||||
}
|
||||
|
||||
fn rom_word(&self, a: u16) -> u16 {
|
||||
self.data.rom.word(a)
|
||||
}
|
||||
|
||||
/// Once per frame.
|
||||
pub fn update(&mut self, apu: &mut Apu) {
|
||||
if let Some(id) = self.queue.pop_front() {
|
||||
self.pending = id;
|
||||
}
|
||||
self.start_pending(apu);
|
||||
self.mark_overridden_voices();
|
||||
for ch in 0..8 {
|
||||
if self.channels[ch].flags & ACTIVE != 0 {
|
||||
self.update_channel(ch, apu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_pending(&mut self, apu: &mut Apu) {
|
||||
let id = self.pending;
|
||||
if id & 0x80 == 0 || id == IDLE {
|
||||
return;
|
||||
}
|
||||
if id >= 0x97 {
|
||||
// out-of-range ids silence the music, whatever is playing
|
||||
self.music_priority = 0;
|
||||
self.reset_chip(apu);
|
||||
self.load_channels(addr::SILENCE_HEADER, 0);
|
||||
return;
|
||||
}
|
||||
let header = self.rom_word(addr::SOUND_HEADERS + 2 * (id - 0x80) as u16);
|
||||
match id {
|
||||
0x81..=0x85 => self.start_music(header, 1, apu),
|
||||
0x86..=0x94 => self.start_sfx(header, 1),
|
||||
PAUSE_MUSIC => {
|
||||
self.sfx_priority += 1;
|
||||
for ch in &mut self.channels[..4] {
|
||||
ch.flags |= PAUSED;
|
||||
}
|
||||
self.start_sfx(header, 2);
|
||||
}
|
||||
RESUME_MUSIC => {
|
||||
// The original then starts a "sound effect" whose header
|
||||
// pointer is garbage (it points into code); just resume.
|
||||
self.written = Written::default();
|
||||
self.sfx_priority += 1;
|
||||
for ch in &mut self.channels[..4] {
|
||||
ch.flags &= !PAUSED;
|
||||
}
|
||||
self.pending = IDLE;
|
||||
}
|
||||
_ => self.pending = IDLE,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_music(&mut self, header: u16, priority: u8, apu: &mut Apu) {
|
||||
if self.music_priority > priority {
|
||||
self.pending = IDLE;
|
||||
return;
|
||||
}
|
||||
self.music_priority = priority;
|
||||
self.reset_chip(apu);
|
||||
self.load_channels(header, 0);
|
||||
}
|
||||
|
||||
fn start_sfx(&mut self, header: u16, priority: u8) {
|
||||
if self.sfx_priority > priority {
|
||||
self.pending = IDLE;
|
||||
return;
|
||||
}
|
||||
self.sfx_priority = priority;
|
||||
self.written = Written::default();
|
||||
for ch in &mut self.channels[4..] {
|
||||
ch.flags = 0;
|
||||
}
|
||||
self.load_channels(header, 4);
|
||||
}
|
||||
|
||||
fn load_channels(&mut self, header: u16, first: usize) {
|
||||
let count = self.rom(header) as usize;
|
||||
for i in 0..count.min(8 - first) {
|
||||
let a = header + 1 + 8 * i as u16;
|
||||
self.channels[first + i] = Channel {
|
||||
flags: self.rom(a),
|
||||
voice: self.rom(a + 1),
|
||||
stereo: self.rom(a + 2),
|
||||
pc: self.rom_word(a + 3),
|
||||
transpose: self.rom(a + 5),
|
||||
vibrato: self.rom(a + 6),
|
||||
envelope: self.rom(a + 7),
|
||||
length: 1,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.pending = IDLE;
|
||||
}
|
||||
|
||||
/// Stop everything and put the chip in a known state (done before each song).
|
||||
fn reset_chip(&mut self, apu: &mut Apu) {
|
||||
for ch in &mut self.channels {
|
||||
ch.flags = 0;
|
||||
}
|
||||
self.load_waveform(1, apu);
|
||||
apu.write(NR52, 0x8F);
|
||||
self.master_volume = 0x77;
|
||||
self.music_wave = 0;
|
||||
self.sfx_wave = 0;
|
||||
self.loaded_wave = 0;
|
||||
self.init_voices(apu);
|
||||
}
|
||||
|
||||
fn init_voices(&mut self, apu: &mut Apu) {
|
||||
apu.write(NR32, 0);
|
||||
apu.write(NR51, 0);
|
||||
apu.write(NR10, 0x08);
|
||||
apu.write(NR12, 0xF0);
|
||||
apu.write(NR22, 0xF0);
|
||||
apu.write(NR42, 0xF0);
|
||||
apu.write(NR30, 0x80);
|
||||
for r in [NR13, NR23, NR33, NR43] {
|
||||
apu.write(r, 0xFF);
|
||||
}
|
||||
apu.write(NR50, self.master_volume);
|
||||
for r in [NR14, NR24, NR34, NR44] {
|
||||
apu.write(r, 0x87);
|
||||
}
|
||||
self.written = Written::default();
|
||||
}
|
||||
|
||||
fn load_waveform(&mut self, n: u8, apu: &mut Apu) {
|
||||
let src = self.rom_word(addr::WAVEFORMS + 2 * n as u16);
|
||||
let power = apu.read(NR52);
|
||||
apu.write(NR52, 0);
|
||||
for i in 0..16 {
|
||||
apu.write(WAVE_RAM + i, self.rom(src + i));
|
||||
}
|
||||
apu.write(NR52, power);
|
||||
self.init_voices(apu);
|
||||
}
|
||||
|
||||
/// A sound effect channel takes over the music channel with the same voice.
|
||||
fn mark_overridden_voices(&mut self) {
|
||||
for ch in &mut self.channels[..4] {
|
||||
ch.flags &= !OVERRIDDEN;
|
||||
}
|
||||
for i in 4..8 {
|
||||
if self.channels[i].flags & ACTIVE != 0 {
|
||||
let voice = self.channels[i].voice as usize;
|
||||
if let Some(m) = self.channels.get_mut(voice) {
|
||||
m.flags |= OVERRIDDEN;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_channel(&mut self, n: usize, apu: &mut Apu) {
|
||||
if self.channels[n].flags & PAUSED != 0 {
|
||||
return;
|
||||
}
|
||||
let ch = &mut self.channels[n];
|
||||
ch.timer = ch.timer.wrapping_add(1);
|
||||
if ch.timer == ch.length {
|
||||
if !self.next_event(n, apu) {
|
||||
return; // channel ended
|
||||
}
|
||||
let ch = &mut self.channels[n];
|
||||
if ch.flags & RETRIGGER != 0 {
|
||||
ch.vibrato_pos = 0;
|
||||
}
|
||||
}
|
||||
if self.channels[n].flags & OVERRIDDEN != 0 {
|
||||
return;
|
||||
}
|
||||
self.update_period(n);
|
||||
match self.channels[n].voice {
|
||||
PULSE1 => self.write_pulse(n, 0, apu),
|
||||
PULSE2 => self.write_pulse(n, 1, apu),
|
||||
WAVE => self.write_wave(n, apu),
|
||||
NOISE => self.write_noise(n, apu),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply pitch slide and vibrato to the note's period.
|
||||
fn update_period(&mut self, n: usize) {
|
||||
let ch = &mut self.channels[n];
|
||||
ch.period = ch.base;
|
||||
if ch.base == 0 {
|
||||
return;
|
||||
}
|
||||
if ch.flags & SLIDING != 0 {
|
||||
ch.slide = add_signed(ch.slide, ch.slide_step);
|
||||
ch.period = ch.slide;
|
||||
}
|
||||
if ch.vibrato == 0 {
|
||||
return;
|
||||
}
|
||||
let table = self.rom_word(addr::VIBRATO_TABLES + 2 * self.channels[n].vibrato as u16);
|
||||
loop {
|
||||
let ch = &mut self.channels[n];
|
||||
let pos = ch.vibrato_pos;
|
||||
ch.vibrato_pos = pos.wrapping_add(1);
|
||||
match self.data.rom.byte(table + pos as u16) {
|
||||
0x80 => ch.vibrato_pos = 0, // restart the table
|
||||
0x81 => ch.vibrato_pos = ch.vibrato_pos.wrapping_sub(2), // repeat previous entry
|
||||
0x82 => {
|
||||
ch.vibrato_pos = ch.vibrato_pos.wrapping_sub(1); // hold
|
||||
return;
|
||||
}
|
||||
0x83 => ch.vibrato_pos = self.data.rom.byte(table + pos as u16 + 1), // jump
|
||||
delta => {
|
||||
ch.period = add_signed(ch.period, delta);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn silence(voice: u8, written: &mut Written, apu: &mut Apu) {
|
||||
match voice {
|
||||
PULSE1 | PULSE2 => {
|
||||
let (lo, hi) = if voice == PULSE1 { (NR13, NR14) } else { (NR23, NR24) };
|
||||
apu.write(lo, 0xFF);
|
||||
apu.write(hi, 0x87);
|
||||
written.lo[voice as usize] = 0xFF;
|
||||
written.hi[voice as usize] = 0x87;
|
||||
}
|
||||
WAVE => {
|
||||
apu.write(NR33, 0xFF);
|
||||
apu.write(NR34, 0x07);
|
||||
written.lo[2] = 0xFF;
|
||||
written.hi[2] = 0x07;
|
||||
}
|
||||
NOISE => {
|
||||
apu.write(NR43, 0xFF);
|
||||
apu.write(NR44, 0x80);
|
||||
written.lo[3] = 0xFF;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_pulse(&mut self, n: usize, voice: usize, apu: &mut Apu) {
|
||||
let [nr1, nr2, nr3, nr4] = if voice == 0 { [NR11, NR12, NR13, NR14] } else { [NR21, NR22, NR23, NR24] };
|
||||
let w = &mut self.written;
|
||||
let ch = &mut self.channels[n];
|
||||
apu.write(nr1, ch.duty & 0xC0);
|
||||
if w.env[voice] != ch.envelope {
|
||||
w.env[voice] = ch.envelope;
|
||||
apu.write(nr2, ch.envelope);
|
||||
}
|
||||
if ch.period == 0 {
|
||||
Self::silence(voice as u8, w, apu);
|
||||
return;
|
||||
}
|
||||
let pan = if voice == 0 { 0x11 } else { 0x22 };
|
||||
apu.write(NR51, (apu.read(NR51) | pan) & ch.stereo);
|
||||
let [lo, hi] = ch.period.to_le_bytes();
|
||||
let retrigger = ch.flags & RETRIGGER != 0;
|
||||
if !retrigger && w.lo[voice] == lo && w.hi[voice] == hi {
|
||||
return;
|
||||
}
|
||||
apu.write(nr3, lo);
|
||||
w.lo[voice] = lo;
|
||||
w.hi[voice] = hi;
|
||||
ch.flags &= !RETRIGGER;
|
||||
apu.write(nr4, (hi & 7) | if retrigger { 0x80 } else { 0 });
|
||||
}
|
||||
|
||||
fn write_wave(&mut self, n: usize, apu: &mut Apu) {
|
||||
let w = &mut self.written;
|
||||
let ch = &mut self.channels[n];
|
||||
if ch.period == 0 {
|
||||
Self::silence(WAVE, w, apu);
|
||||
return;
|
||||
}
|
||||
if w.env[2] != ch.envelope {
|
||||
w.env[2] = ch.envelope;
|
||||
apu.write(NR32, ch.envelope & 0x60);
|
||||
}
|
||||
apu.write(NR51, (apu.read(NR51) | 0x44) & ch.stereo);
|
||||
let [lo, hi] = ch.period.to_le_bytes();
|
||||
if w.lo[2] == lo && w.hi[2] == hi {
|
||||
return;
|
||||
}
|
||||
apu.write(NR33, lo);
|
||||
w.lo[2] = lo;
|
||||
w.hi[2] = hi;
|
||||
apu.write(NR34, hi & 7); // the wave voice is never restarted
|
||||
}
|
||||
|
||||
fn write_noise(&mut self, n: usize, apu: &mut Apu) {
|
||||
let w = &mut self.written;
|
||||
let ch = &mut self.channels[n];
|
||||
if w.env[3] != ch.envelope {
|
||||
w.env[3] = ch.envelope;
|
||||
apu.write(NR42, ch.envelope);
|
||||
}
|
||||
let poly = ch.period as u8;
|
||||
if poly == 0 {
|
||||
Self::silence(NOISE, w, apu);
|
||||
return;
|
||||
}
|
||||
apu.write(NR51, (apu.read(NR51) | 0x88) & ch.stereo);
|
||||
if w.lo[3] == poly {
|
||||
return;
|
||||
}
|
||||
apu.write(NR43, poly);
|
||||
w.lo[3] = poly;
|
||||
// (sic) noise restarts only when the note is *not* flagged to retrigger
|
||||
let restart = if ch.flags & RETRIGGER != 0 { 0 } else { 0x80 };
|
||||
ch.flags &= !RETRIGGER;
|
||||
apu.write(NR44, restart);
|
||||
}
|
||||
|
||||
/// Run byte code up to the next note. Returns false if the channel ended.
|
||||
fn next_event(&mut self, n: usize, apu: &mut Apu) -> bool {
|
||||
self.channels[n].flags |= RETRIGGER;
|
||||
let mut pc = self.channels[n].pc;
|
||||
loop {
|
||||
let op = self.rom(pc);
|
||||
pc += 1;
|
||||
if op >= 0xE0 {
|
||||
match self.command(n, op, &mut pc, apu) {
|
||||
Some(()) => continue,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
let ch = &mut self.channels[n];
|
||||
if op < 0x80 {
|
||||
ch.length = op;
|
||||
} else {
|
||||
let note = if op == 0x80 { op } else { op.wrapping_add(ch.transpose) };
|
||||
ch.base = self.data.rom.word(addr::NOTE_PERIODS + (note as u16 * 2 & 0xFF));
|
||||
ch.slide = ch.base;
|
||||
if ch.flags & SLIDING != 0 {
|
||||
ch.slide_step = self.data.rom.byte(pc);
|
||||
pc += 1;
|
||||
}
|
||||
let next = self.data.rom.byte(pc);
|
||||
if next & 0x80 == 0 {
|
||||
ch.length = next;
|
||||
pc += 1;
|
||||
}
|
||||
}
|
||||
ch.pc = pc;
|
||||
ch.timer = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one $E0-$F1 command. None = the channel stopped.
|
||||
fn command(&mut self, n: usize, op: u8, pc: &mut u16, apu: &mut Apu) -> Option<()> {
|
||||
let arg = self.rom(*pc);
|
||||
let target = self.rom_word(*pc);
|
||||
let ch = &mut self.channels[n];
|
||||
match op {
|
||||
0xE0 | 0xE1 => {
|
||||
ch.return_stack.push(*pc + 2);
|
||||
*pc = target;
|
||||
}
|
||||
0xE2 => *pc = ch.return_stack.pop().unwrap_or(*pc),
|
||||
0xE3 => {
|
||||
self.music_priority = 0;
|
||||
self.stop_channel(n, apu);
|
||||
return None;
|
||||
}
|
||||
0xE4 => {
|
||||
let counter = &mut ch.loop_counters[arg as usize];
|
||||
if *counter == 0 {
|
||||
*counter = self.data.rom.byte(*pc + 1);
|
||||
}
|
||||
*counter -= 1;
|
||||
*pc = if *counter == 0 { *pc + 4 } else { self.data.rom.word(*pc + 2) };
|
||||
}
|
||||
0xE5 => *pc = target,
|
||||
0xE6 => (ch.envelope, *pc) = (arg, *pc + 1),
|
||||
0xE7 => (ch.transpose, *pc) = (ch.transpose.wrapping_add(arg), *pc + 1),
|
||||
0xE8 => (ch.duty, *pc) = (arg, *pc + 1),
|
||||
0xE9 => (ch.stereo, *pc) = (arg, *pc + 1),
|
||||
0xEA => {
|
||||
*pc += 1;
|
||||
self.select_waveform(n, arg, apu);
|
||||
}
|
||||
0xEB => {
|
||||
self.sfx_priority = 0;
|
||||
if self.music_wave != self.loaded_wave {
|
||||
self.loaded_wave = self.music_wave;
|
||||
self.load_waveform(self.music_wave, apu);
|
||||
}
|
||||
self.stop_channel(n, apu);
|
||||
return None;
|
||||
}
|
||||
0xEC => ch.flags |= SLIDING,
|
||||
0xED => ch.flags &= !SLIDING,
|
||||
0xEE => ch.flags &= !RETRIGGER,
|
||||
0xEF => {
|
||||
*pc += 1;
|
||||
self.master_volume = arg;
|
||||
apu.write(NR50, arg);
|
||||
}
|
||||
0xF0 => (ch.vibrato, *pc) = (arg, *pc + 1),
|
||||
0xF1 => (ch.base, *pc) = ((ch.base & 0xFF00) | arg as u16, *pc + 1),
|
||||
_ => {} // unused opcodes
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn select_waveform(&mut self, n: usize, w: u8, apu: &mut Apu) {
|
||||
if self.channels[n].flags & OVERRIDDEN != 0 {
|
||||
self.music_wave = w;
|
||||
return;
|
||||
}
|
||||
if w == self.loaded_wave {
|
||||
return;
|
||||
}
|
||||
self.loaded_wave = w;
|
||||
// (sic) the original counts only channels 0-2 as music here
|
||||
if n < 3 {
|
||||
self.music_wave = w;
|
||||
} else {
|
||||
self.sfx_wave = w;
|
||||
}
|
||||
self.load_waveform(w, apu);
|
||||
}
|
||||
|
||||
fn stop_channel(&mut self, n: usize, apu: &mut Apu) {
|
||||
self.channels[n].flags = 0;
|
||||
Self::silence(self.channels[n].voice, &mut self.written, apu);
|
||||
}
|
||||
}
|
||||
|
||||
/// period + signed 8-bit delta
|
||||
fn add_signed(v: u16, delta: u8) -> u16 {
|
||||
v.wrapping_add(delta as i8 as u16)
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
//! The display: tile graphics, two 32x32 background maps, 40 hardware
|
||||
//! sprites and three palettes, rendered to a 160x144 image of 4 shades.
|
||||
//!
|
||||
//! This is the model the game's art is built for, so the game draws by
|
||||
//! writing tiles into maps and placing sprites, exactly as the original did.
|
||||
//! The game never scrolls and never uses the window layer, so neither exists
|
||||
//! here.
|
||||
|
||||
use crate::rom::{DrawScript, Tiles};
|
||||
|
||||
pub const SCREEN_W: usize = 160;
|
||||
pub const SCREEN_H: usize = 144;
|
||||
|
||||
/// Background map base addresses (the game addresses tiles by VRAM address,
|
||||
/// and so do its draw scripts).
|
||||
pub const MAP0: u16 = 0x9800;
|
||||
pub const MAP1: u16 = 0x9C00;
|
||||
pub const MAP_SIZE: usize = 0x400;
|
||||
|
||||
/// Address of the map cell at (column, row).
|
||||
pub const fn map_cell(map: u16, col: u16, row: u16) -> u16 {
|
||||
map + row * 32 + col
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Sprite {
|
||||
/// screen y + 16 (0 hides the sprite)
|
||||
pub y: u8,
|
||||
/// screen x + 8
|
||||
pub x: u8,
|
||||
pub tile: u8,
|
||||
/// bit 4 = palette 1, bit 5 = x flip, bit 6 = y flip, bit 7 = behind background
|
||||
pub attr: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Display {
|
||||
Off,
|
||||
/// background (from the given map) and sprites
|
||||
On(u16),
|
||||
}
|
||||
|
||||
pub struct Video {
|
||||
vram: Box<[u8; 0x2000]>,
|
||||
pub sprites: [Sprite; 40],
|
||||
pub bg_palette: u8,
|
||||
pub obj_palettes: [u8; 2],
|
||||
pub display: Display,
|
||||
}
|
||||
|
||||
impl Video {
|
||||
pub fn new() -> Video {
|
||||
Video {
|
||||
vram: Box::new([0; 0x2000]),
|
||||
sprites: [Sprite::default(); 40],
|
||||
bg_palette: 0,
|
||||
obj_palettes: [0; 2],
|
||||
display: Display::Off,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peek(&self, addr: u16) -> u8 {
|
||||
self.vram[(addr & 0x1FFF) as usize]
|
||||
}
|
||||
|
||||
pub fn poke(&mut self, addr: u16, v: u8) {
|
||||
if (0x8000..0xA000).contains(&addr) {
|
||||
self.vram[(addr - 0x8000) as usize] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy tile graphics (or anything else) into VRAM.
|
||||
pub fn load(&mut self, addr: u16, bytes: &[u8]) {
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
self.poke(addr.wrapping_add(i as u16), b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill both background maps with one tile.
|
||||
pub fn fill_maps(&mut self, tile: u8) {
|
||||
self.vram[0x1800..0x2000].fill(tile);
|
||||
}
|
||||
|
||||
pub fn copy_map(&mut self, from: u16, to: u16) {
|
||||
let (f, t) = ((from - 0x8000) as usize, (to - 0x8000) as usize);
|
||||
self.vram.copy_within(f..f + MAP_SIZE, t);
|
||||
}
|
||||
|
||||
/// Write `n` copies of `tile` along a row.
|
||||
pub fn fill_row(&mut self, addr: u16, tile: u8, n: u16) {
|
||||
for i in 0..n {
|
||||
self.poke(addr + i, tile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, script: &DrawScript) {
|
||||
for cmd in &script.0 {
|
||||
let step = if cmd.vertical { 32 } else { 1 };
|
||||
let mut a = cmd.addr;
|
||||
let mut put = |t| {
|
||||
self.poke(a, t);
|
||||
a = a.wrapping_add(step);
|
||||
};
|
||||
match &cmd.tiles {
|
||||
Tiles::Run(t, n) => (0..*n).for_each(|_| put(*t)),
|
||||
Tiles::List(ts) => ts.iter().for_each(|&t| put(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_pixel(&self, tile_addr: usize, x: usize, y: usize) -> u8 {
|
||||
let lo = self.vram[tile_addr + y * 2];
|
||||
let hi = self.vram[tile_addr + y * 2 + 1];
|
||||
((lo >> (7 - x)) & 1) | (((hi >> (7 - x)) & 1) << 1)
|
||||
}
|
||||
|
||||
/// Background tiles 0-127 come from $9000, 128-255 from $8800.
|
||||
fn bg_tile_addr(tile: u8) -> usize {
|
||||
(0x1000 + (tile as i8 as isize) * 16) as usize
|
||||
}
|
||||
|
||||
/// Render the frame as shades 0 (white) to 3 (black).
|
||||
pub fn render(&self, out: &mut [u8]) {
|
||||
let map = match self.display {
|
||||
Display::Off => {
|
||||
out.fill(0);
|
||||
return;
|
||||
}
|
||||
Display::On(map) => (map - 0x8000) as usize,
|
||||
};
|
||||
let shade = |pal: u8, c: u8| (pal >> (c * 2)) & 3;
|
||||
let mut bg_color = [0u8; SCREEN_W * SCREEN_H];
|
||||
for y in 0..SCREEN_H {
|
||||
for x in 0..SCREEN_W {
|
||||
let tile = self.vram[map + (y / 8) * 32 + x / 8];
|
||||
let c = self.tile_pixel(Self::bg_tile_addr(tile), x % 8, y % 8);
|
||||
bg_color[y * SCREEN_W + x] = c;
|
||||
out[y * SCREEN_W + x] = shade(self.bg_palette, c);
|
||||
}
|
||||
}
|
||||
// lower sprite numbers win, so draw from the end
|
||||
for s in self.sprites.iter().rev() {
|
||||
let (sy, sx) = (s.y as i32 - 16, s.x as i32 - 8);
|
||||
let pal = self.obj_palettes[(s.attr >> 4 & 1) as usize];
|
||||
for ty in 0..8 {
|
||||
let py = sy + ty as i32;
|
||||
if !(0..SCREEN_H as i32).contains(&py) {
|
||||
continue;
|
||||
}
|
||||
let row = if s.attr & 0x40 != 0 { 7 - ty } else { ty };
|
||||
for tx in 0..8 {
|
||||
let px = sx + tx as i32;
|
||||
if !(0..SCREEN_W as i32).contains(&px) {
|
||||
continue;
|
||||
}
|
||||
let col = if s.attr & 0x20 != 0 { 7 - tx } else { tx };
|
||||
let c = self.tile_pixel(s.tile as usize * 16, col, row);
|
||||
let i = py as usize * SCREEN_W + px as usize;
|
||||
if c == 0 || (s.attr & 0x80 != 0 && bg_color[i] != 0) {
|
||||
continue;
|
||||
}
|
||||
out[i] = shade(pal, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user