From 078a69d4a5aa9c54b13d6288511c3ec1b6808505 Mon Sep 17 00:00:00 2001 From: Jonathan 'theJPster' Pallant Date: Sun, 29 Dec 2024 19:03:35 +0000 Subject: [PATCH] Add neoplay and snake. Taken from Neotron-Apps. --- Cargo.lock | 39 +++ Cargo.toml | 2 +- nbuild/src/main.rs | 17 + utilities/neoplay/Cargo.toml | 14 + utilities/neoplay/README.md | 27 ++ utilities/neoplay/build.rs | 3 + utilities/neoplay/src/main.rs | 88 +++++ utilities/neoplay/src/player.rs | 285 +++++++++++++++++ utilities/snake/Cargo.toml | 12 + utilities/snake/build.rs | 3 + utilities/snake/src/lib.rs | 547 ++++++++++++++++++++++++++++++++ utilities/snake/src/main.rs | 15 + 12 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 utilities/neoplay/Cargo.toml create mode 100644 utilities/neoplay/README.md create mode 100644 utilities/neoplay/build.rs create mode 100644 utilities/neoplay/src/main.rs create mode 100644 utilities/neoplay/src/player.rs create mode 100644 utilities/snake/Cargo.toml create mode 100644 utilities/snake/build.rs create mode 100644 utilities/snake/src/lib.rs create mode 100644 utilities/snake/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index f9116c5..5dd2778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ dependencies = [ "neotron-sdk", ] +[[package]] +name = "grounded" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917d82402c7eb9755fdd87d52117701dae9e413a6abb309fac2a13af693b6080" +dependencies = [ + "portable-atomic", +] + [[package]] name = "hash32" version = "0.2.1" @@ -180,6 +189,20 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "neoplay" +version = "0.1.0" +dependencies = [ + "grounded", + "neotracker", + "neotron-sdk", +] + +[[package]] +name = "neotracker" +version = "0.1.0" +source = "git+https://github.com/thejpster/neotracker.git?rev=2ee7a85006a9461b876bdf47e45b6105437a38f6#2ee7a85006a9461b876bdf47e45b6105437a38f6" + [[package]] name = "neotron-api" version = "0.1.0" @@ -300,6 +323,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed089a1fbffe3337a1a345501c981f1eb1e47e69de5a40e852433e12953c3174" +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +dependencies = [ + "critical-section", +] + [[package]] name = "postcard" version = "1.0.8" @@ -421,6 +453,13 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snake" +version = "0.1.0" +dependencies = [ + "neotron-sdk", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/Cargo.toml b/Cargo.toml index 2b7a204..466312f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ "neotron-os", - "utilities/flames", + "utilities/*", ] resolver = "2" exclude = [ diff --git a/nbuild/src/main.rs b/nbuild/src/main.rs index f083b88..b8c3331 100644 --- a/nbuild/src/main.rs +++ b/nbuild/src/main.rs @@ -42,6 +42,7 @@ pub struct NBuildApp { fn packages() -> Vec { vec![ + // *** build system *** nbuild::Package { name: "nbuild", path: std::path::Path::new("./nbuild/Cargo.toml"), @@ -49,6 +50,7 @@ fn packages() -> Vec { kind: nbuild::PackageKind::NBuild, testable: nbuild::Testable::All, }, + // *** utilities *** nbuild::Package { name: "flames", path: std::path::Path::new("./utilities/flames/Cargo.toml"), @@ -56,6 +58,21 @@ fn packages() -> Vec { kind: nbuild::PackageKind::Utility, testable: nbuild::Testable::No, }, + nbuild::Package { + name: "neoplay", + path: std::path::Path::new("./utilities/neoplay/Cargo.toml"), + output_template: Some("./target/{target}/{profile}/neoplay"), + kind: nbuild::PackageKind::Utility, + testable: nbuild::Testable::No, + }, + nbuild::Package { + name: "snake", + path: std::path::Path::new("./utilities/snake/Cargo.toml"), + output_template: Some("./target/{target}/{profile}/snake"), + kind: nbuild::PackageKind::Utility, + testable: nbuild::Testable::No, + }, + // *** OS *** nbuild::Package { name: "Neotron OS", path: std::path::Path::new("./neotron-os/Cargo.toml"), diff --git a/utilities/neoplay/Cargo.toml b/utilities/neoplay/Cargo.toml new file mode 100644 index 0000000..79cc81a --- /dev/null +++ b/utilities/neoplay/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "neoplay" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["Jonathan 'theJPster' Pallant "] +description = "4-channel ProTracker player for Neotro" + +[dependencies] +grounded = { version = "0.2.0", features = ["critical-section", "cas"] } +neotracker = { git = "https://github.com/thejpster/neotracker.git", rev = "2ee7a85006a9461b876bdf47e45b6105437a38f6" } +neotron-sdk = { workspace = true } + +# See workspace for profile settings diff --git a/utilities/neoplay/README.md b/utilities/neoplay/README.md new file mode 100644 index 0000000..12bf573 --- /dev/null +++ b/utilities/neoplay/README.md @@ -0,0 +1,27 @@ +# Neoplay + +A ProTracker MOD player for the Neotron Pico. + +Runs at 11,025 Hz, quadrupling samples for the audio codec which runs at 44,100 Hz. + +```console +$ cargo build --release --target=thumbv6m-none-eabi +$ cp ../target/thumbv6m-none-eabi/release/neoplay /media/USER/SDCARD/NEOPLAY.ELF + +``` + +```console +> load neoplay.elf +> run airwolf.mod +Loading "airwolf.mod" +audio 44100, SixteenBitStereo +Playing "airwolf.mod" + +000 000000 12 00fe 0f04|-- ---- ----|-- ---- ----|-- ---- ----| +000 000001 -- ---- ----|-- ---- ----|-- ---- ----|-- ---- ----| +000 000002 -- ---- ----|-- ---- ----|-- ---- ----|-- ---- ----| +000 000003 -- ---- ----|-- ---- ----|-- ---- ----|-- ---- ----| +etc +``` + +Here's a video of it in action: https://youtu.be/ONZhDrZsmDU diff --git a/utilities/neoplay/build.rs b/utilities/neoplay/build.rs new file mode 100644 index 0000000..821fb1a --- /dev/null +++ b/utilities/neoplay/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-arg-bin=neoplay=-Tneotron-cortex-m.ld"); +} diff --git a/utilities/neoplay/src/main.rs b/utilities/neoplay/src/main.rs new file mode 100644 index 0000000..91773ec --- /dev/null +++ b/utilities/neoplay/src/main.rs @@ -0,0 +1,88 @@ +#![cfg_attr(target_os = "none", no_std)] +#![cfg_attr(target_os = "none", no_main)] + +use core::{fmt::Write, ptr::addr_of_mut}; + +const FILE_BUFFER_LEN: usize = 192 * 1024; +static mut FILE_BUFFER: [u8; FILE_BUFFER_LEN] = [0u8; FILE_BUFFER_LEN]; + +mod player; + +#[cfg(not(target_os = "none"))] +fn main() { + neotron_sdk::init(); +} + +#[no_mangle] +extern "C" fn neotron_main() -> i32 { + if let Err(e) = real_main() { + let mut stdout = neotron_sdk::stdout(); + let _ = writeln!(stdout, "Error: {:?}", e); + 1 + } else { + 0 + } +} + +fn real_main() -> Result<(), neotron_sdk::Error> { + let mut stdout = neotron_sdk::stdout(); + let stdin = neotron_sdk::stdin(); + let Some(filename) = neotron_sdk::arg(0) else { + return Err(neotron_sdk::Error::InvalidArg); + }; + let _ = writeln!(stdout, "Loading {:?}...", filename); + let path = neotron_sdk::path::Path::new(&filename)?; + let f = neotron_sdk::File::open(path, neotron_sdk::Flags::empty())?; + let file_buffer = unsafe { + let file_buffer = &mut *addr_of_mut!(FILE_BUFFER); + let n = f.read(file_buffer)?; + &file_buffer[0..n] + }; + drop(f); + // Set 16-bit stereo, 44.1 kHz + let dsp_path = neotron_sdk::path::Path::new("AUDIO:")?; + let dsp = neotron_sdk::File::open(dsp_path, neotron_sdk::Flags::empty())?; + if dsp.ioctl(1, 3 << 60 | 44100).is_err() { + let _ = writeln!(stdout, "Failed to configure audio"); + return neotron_sdk::Result::Err(neotron_sdk::Error::DeviceSpecific); + } + + let mut player = match player::Player::new(file_buffer, 44100) { + Ok(player) => player, + Err(e) => { + let _ = writeln!(stdout, "Failed to create player: {:?}", e); + return Err(neotron_sdk::Error::InvalidArg); + } + }; + + let _ = writeln!(stdout, "Playing {:?}...", filename); + let mut sample_buffer = [0u8; 1024]; + // loop some some silence to give us a head-start + for _i in 0..11 { + let _ = dsp.write(&sample_buffer); + } + + loop { + for chunk in sample_buffer.chunks_exact_mut(4) { + let (left, right) = player.next_sample(&mut stdout); + let left_bytes = left.to_le_bytes(); + let right_bytes = right.to_le_bytes(); + chunk[0] = left_bytes[0]; + chunk[1] = left_bytes[1]; + chunk[2] = right_bytes[0]; + chunk[3] = right_bytes[1]; + } + let _ = dsp.write(&sample_buffer); + let mut in_buf = [0u8; 1]; + if player.is_finished() { + break; + } + if stdin.read(&mut in_buf).is_ok() && in_buf[0].to_ascii_lowercase() == b'q' { + break; + } + } + + let _ = writeln!(stdout, "Bye!"); + + Ok(()) +} diff --git a/utilities/neoplay/src/player.rs b/utilities/neoplay/src/player.rs new file mode 100644 index 0000000..0c74e94 --- /dev/null +++ b/utilities/neoplay/src/player.rs @@ -0,0 +1,285 @@ +//! Plays a MOD file. + +#[derive(Debug, Default)] +struct Channel { + sample_data: Option<*const u8>, + sample_loops: bool, + sample_length: usize, + repeat_length: usize, + repeat_point: usize, + volume: u8, + note_period: u16, + sample_position: neotracker::Fractional, + note_step: neotracker::Fractional, + effect: Option, +} + +pub struct Player<'a> { + modfile: neotracker::ProTrackerModule<'a>, + /// How many samples left in this tick + samples_left: u32, + /// How many ticks left in this line + ticks_left: u32, + ticks_per_line: u32, + third_ticks_per_line: u32, + samples_per_tick: u32, + clock_ticks_per_device_sample: neotracker::Fractional, + position: u8, + line: u8, + finished: bool, + /// This is set when we get a Pattern Break (0xDxx) effect. It causes + /// us to jump to a specific row in the next pattern. + pattern_break: Option, + channels: [Channel; 4], +} + +/// This code is based on https://www.codeslow.com/2019/02/in-this-post-we-will-finally-have-some.html?m=1 +impl<'a> Player<'a> { + /// Make a new player, at the given sample rate. + pub fn new(data: &'static [u8], sample_rate: u32) -> Result, neotracker::Error> { + // We need a 'static reference to this data, and we're not going to free it. + // So just leak it. + let modfile = neotracker::ProTrackerModule::new(data)?; + Ok(Player { + modfile, + samples_left: 0, + ticks_left: 0, + ticks_per_line: 6, + third_ticks_per_line: 2, + samples_per_tick: sample_rate / 50, + position: 0, + line: 0, + finished: false, + clock_ticks_per_device_sample: neotracker::Fractional::new_from_sample_rate( + sample_rate, + ), + pattern_break: None, + channels: [ + Channel::default(), + Channel::default(), + Channel::default(), + Channel::default(), + ], + }) + } + + /// Are we finished playing? + pub fn is_finished(&self) -> bool { + self.finished + } + + /// Return a stereo sample pair + pub fn next_sample(&mut self, out: &mut T) -> (i16, i16) + where + T: core::fmt::Write, + { + if self.ticks_left == 0 && self.samples_left == 0 { + // It is time for a new line + + // Did we have a pattern break? Jump straight there. + if let Some(line) = self.pattern_break { + self.pattern_break = None; + self.position += 1; + self.line = line; + } + + // Find which line we play next. It might be the next line in this + // pattern, or it might be the first line in the next pattern. + let line = loop { + // Work out which pattern we're playing + let Some(pattern_idx) = self.modfile.song_position(self.position) else { + self.finished = true; + return (0, 0); + }; + // Grab the pattern + let pattern = self.modfile.pattern(pattern_idx).expect("Get pattern"); + // Get the line from the pattern + let Some(line) = pattern.line(self.line) else { + // Go to start of next pattern + self.line = 0; + self.position += 1; + continue; + }; + // There was no need to go the next pattern, so produce this + // line from the loop. + break line; + }; + + // Load four channels with new line data + let _ = write!(out, "{:03} {:06}: ", self.position, self.line); + for (channel_num, ch) in self.channels.iter_mut().enumerate() { + let note = &line.channel[channel_num]; + // Do we have a new sample to play? + if note.is_empty() { + let _ = write!(out, "--- -----|"); + } else { + if let Some(sample) = self.modfile.sample(note.sample_no()) { + // if the period is zero, keep playing the old note + if note.period() != 0 { + ch.note_period = note.period(); + ch.note_step = self + .clock_ticks_per_device_sample + .apply_period(ch.note_period); + } + ch.volume = sample.volume(); + ch.sample_data = Some(sample.raw_sample_bytes().as_ptr()); + ch.sample_loops = sample.loops(); + ch.sample_length = sample.sample_length_bytes(); + ch.repeat_length = sample.repeat_length_bytes(); + ch.repeat_point = sample.repeat_point_bytes(); + ch.sample_position = neotracker::Fractional::default(); + } + let _ = write!( + out, + "{:3x} {:02}{:03x}|", + note.period(), + note.sample_no(), + note.effect_u16() + ); + } + ch.effect = None; + match note.effect() { + e @ Some( + neotracker::Effect::Arpeggio(_) + | neotracker::Effect::SlideUp(_) + | neotracker::Effect::SlideDown(_) + | neotracker::Effect::VolumeSlide(_), + ) => { + // we'll need this for later + ch.effect = e; + } + Some(neotracker::Effect::SetVolume(value)) => { + ch.volume = value; + } + Some(neotracker::Effect::SetSpeed(value)) => { + if value <= 31 { + self.ticks_per_line = u32::from(value); + self.third_ticks_per_line = u32::from(value / 3); + } else { + // They are trying to set speed in beats per minute + } + } + Some(neotracker::Effect::SampleOffset(n)) => { + let offset = u32::from(n) * 256; + ch.sample_position = neotracker::Fractional::new(offset); + } + Some(neotracker::Effect::PatternBreak(row)) => { + // Start the next pattern early, at the given row + self.pattern_break = Some(row); + } + Some(_e) => { + // eprintln!("Unhandled effect {:02x?}", e); + } + None => { + // Do nothing + } + } + } + let _ = writeln!(out); + + self.line += 1; + self.samples_left = self.samples_per_tick - 1; + self.ticks_left = self.ticks_per_line - 1; + } else if self.samples_left == 0 { + // end of a tick + self.samples_left = self.samples_per_tick - 1; + self.ticks_left -= 1; + let lower_third = self.third_ticks_per_line; + let upper_third = lower_third * 2; + for ch in self.channels.iter_mut() { + match ch.effect { + Some(neotracker::Effect::Arpeggio(n)) => { + if self.ticks_left == upper_third { + let half_steps = n >> 4; + if let Some(new_period) = + neotracker::shift_period(ch.note_period, half_steps) + { + ch.note_period = new_period; + ch.note_step = self + .clock_ticks_per_device_sample + .apply_period(ch.note_period); + } + } else if self.ticks_left == lower_third { + let first_half_steps = n >> 4; + let second_half_steps = n & 0x0F; + if let Some(new_period) = neotracker::shift_period( + ch.note_period, + second_half_steps - first_half_steps, + ) { + ch.note_period = new_period; + ch.note_step = self + .clock_ticks_per_device_sample + .apply_period(ch.note_period); + } + } + } + Some(neotracker::Effect::SlideUp(n)) => { + ch.note_period -= u16::from(n); + ch.note_step = self + .clock_ticks_per_device_sample + .apply_period(ch.note_period); + } + Some(neotracker::Effect::SlideDown(n)) => { + ch.note_period += u16::from(n); + ch.note_step = self + .clock_ticks_per_device_sample + .apply_period(ch.note_period); + } + Some(neotracker::Effect::VolumeSlide(n)) => { + let new_volume = (ch.volume as i8) + n; + if (0..=63).contains(&new_volume) { + ch.volume = new_volume as u8; + } + } + _ => { + // do nothing + } + } + } + } else { + // just another sample + self.samples_left -= 1; + } + + // Pump existing channels + let mut left_sample = 0; + let mut right_sample = 0; + for (ch_idx, ch) in self.channels.iter_mut().enumerate() { + if ch.note_period == 0 || ch.sample_length == 0 { + continue; + } + let Some(sample_data) = ch.sample_data else { + continue; + }; + let integer_pos = ch.sample_position.as_index(); + let sample_byte = unsafe { sample_data.add(integer_pos).read() } as i8; + let mut channel_value = (sample_byte as i8) as i32; + // max channel vol (64), sample range [-128,127] scaled to [-32768, 32767] + channel_value *= 256; + channel_value *= i32::from(ch.volume); + channel_value /= 64; + // move the sample index by a non-integer amount + ch.sample_position += ch.note_step; + // loop sample if required + if ch.sample_loops { + if ch.sample_position.as_index() >= (ch.repeat_point + ch.repeat_length) { + ch.sample_position = neotracker::Fractional::new(ch.repeat_point as u32); + } + } else if ch.sample_position.as_index() >= ch.sample_length { + // stop playing sample + ch.note_period = 0; + } + + if ch_idx == 0 || ch_idx == 3 { + left_sample += channel_value; + } else { + right_sample += channel_value; + } + } + + ( + left_sample.clamp(-32768, 32767) as i16, + right_sample.clamp(-32768, 32767) as i16, + ) + } +} diff --git a/utilities/snake/Cargo.toml b/utilities/snake/Cargo.toml new file mode 100644 index 0000000..e5cc3c3 --- /dev/null +++ b/utilities/snake/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "snake" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["Jonathan 'theJPster' Pallant "] +description = "ANSI Snake for Neotron systems" + +[dependencies] +neotron-sdk = { workspace = true } + +# See workspace for profile settings diff --git a/utilities/snake/build.rs b/utilities/snake/build.rs new file mode 100644 index 0000000..6bf17c2 --- /dev/null +++ b/utilities/snake/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-link-arg-bin=snake=-Tneotron-cortex-m.ld"); +} diff --git a/utilities/snake/src/lib.rs b/utilities/snake/src/lib.rs new file mode 100644 index 0000000..b909126 --- /dev/null +++ b/utilities/snake/src/lib.rs @@ -0,0 +1,547 @@ +//! Game logic for Snake + +#![no_std] +#![deny(missing_docs)] +#![deny(unsafe_code)] + +use core::fmt::Write; + +use neotron_sdk::console; + +/// Represents the Snake application +/// +/// An application can play multiple games. +pub struct App { + game: Game, + width: u8, + height: u8, + stdout: neotron_sdk::File, + stdin: neotron_sdk::File, +} + +impl App { + /// Make a new snake application. + /// + /// You can give the screen size in characters. There will be a border and + /// the board will be two units smaller in each axis. + pub const fn new(width: u8, height: u8) -> App { + App { + game: Game::new(width - 2, height - 2, console::Position { row: 1, col: 1 }), + width, + height, + stdout: neotron_sdk::stdout(), + stdin: neotron_sdk::stdin(), + } + } + + /// Play multiple games of snake. + /// + /// Loops playing games and printing scores. + pub fn play(&mut self) { + console::cursor_off(&mut self.stdout); + self.clear_screen(); + self.title_screen(); + + let mut seed: u16 = 0x4f34; + + 'outer: loop { + 'inner: loop { + let key = self.wait_for_key(); + seed = seed.wrapping_add(1); + if key == b'q' || key == b'Q' { + break 'outer; + } + if key == b'p' || key == b'P' { + break 'inner; + } + } + + self.clear_screen(); + + neotron_sdk::srand(seed); + + let score = self.game.play(&mut self.stdin, &mut self.stdout); + + self.winning_message(score); + } + + // show cursor + console::cursor_on(&mut self.stdout); + self.clear_screen(); + } + + /// Clear the screen and draw the board. + fn clear_screen(&mut self) { + console::set_sgr(&mut self.stdout, [console::SgrParam::Reset]); + console::clear_screen(&mut self.stdout); + console::set_sgr( + &mut self.stdout, + [ + console::SgrParam::Bold, + console::SgrParam::FgYellow, + console::SgrParam::BgBlack, + ], + ); + console::move_cursor(&mut self.stdout, console::Position::origin()); + let _ = self.stdout.write_char('╔'); + for _ in 1..self.width - 1 { + let _ = self.stdout.write_char('═'); + } + let _ = self.stdout.write_char('╗'); + console::move_cursor( + &mut self.stdout, + console::Position { + row: self.height - 1, + col: 0, + }, + ); + let _ = self.stdout.write_char('╚'); + for _ in 1..self.width - 1 { + let _ = self.stdout.write_char('═'); + } + let _ = self.stdout.write_char('╝'); + for row in 1..self.height - 1 { + console::move_cursor(&mut self.stdout, console::Position { row, col: 0 }); + let _ = self.stdout.write_char('║'); + console::move_cursor( + &mut self.stdout, + console::Position { + row, + col: self.width - 1, + }, + ); + let _ = self.stdout.write_char('║'); + } + console::set_sgr(&mut self.stdout, [console::SgrParam::Reset]); + } + + /// Show the title screen + fn title_screen(&mut self) { + console::set_sgr(&mut self.stdout, [console::SgrParam::Reset]); + let message = "Neotron Snake by theJPster"; + let pos = console::Position { + row: self.height / 2, + col: (self.width - message.chars().count() as u8) / 2, + }; + console::move_cursor(&mut self.stdout, pos); + let _ = self.stdout.write_str(message); + let message = "Q to Quit | 'P' to Play"; + let pos = console::Position { + row: pos.row + 1, + col: (self.width - message.chars().count() as u8) / 2, + }; + console::move_cursor(&mut self.stdout, pos); + let _ = self.stdout.write_str(message); + } + + /// Spin until a key is pressed + fn wait_for_key(&mut self) -> u8 { + loop { + let mut buffer = [0u8; 1]; + if let Ok(1) = self.stdin.read(&mut buffer) { + return buffer[0]; + } + neotron_sdk::delay(core::time::Duration::from_millis(10)); + } + } + + /// Print the game over message with the given score + fn winning_message(&mut self, score: u32) { + console::set_sgr(&mut self.stdout, [console::SgrParam::Reset]); + let pos = console::Position { + row: self.height / 2, + col: (self.width - 13u8) / 2, + }; + console::move_cursor(&mut self.stdout, pos); + let _ = writeln!(self.stdout, "Score: {:06}", score); + let message = "Q to Quit | 'P' to Play"; + let pos = console::Position { + row: pos.row + 1, + col: (self.width - message.chars().count() as u8) / 2, + }; + console::move_cursor(&mut self.stdout, pos); + let _ = self.stdout.write_str(message); + } +} + +/// Something we can send to the ANSI console +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum Piece { + Head, + Food, + Body, +} + +impl Piece { + /// Get the Unicode char for this piece + fn get_char(self) -> char { + match self { + Piece::Body => '▓', + Piece::Head => '█', + Piece::Food => '▲', + } + } + + /// Get the ANSI colour for this piece + fn get_colour(self) -> console::SgrParam { + match self { + Piece::Body => console::SgrParam::FgMagenta, + Piece::Head => console::SgrParam::FgYellow, + Piece::Food => console::SgrParam::FgGreen, + } + } +} + +/// Represents one game of Snake +struct Game { + board: Board<{ Self::MAX_WIDTH }, { Self::MAX_HEIGHT }>, + width: u8, + height: u8, + offset: console::Position, + head: console::Position, + tail: console::Position, + direction: Direction, + score: u32, + digesting: u32, + tick_interval_ms: u16, +} + +impl Game { + /// The maximum width board we can handle + pub const MAX_WIDTH: usize = 78; + /// The maximum height board we can handle + pub const MAX_HEIGHT: usize = 23; + /// How many ms per tick do we start at? + const STARTING_TICK: u16 = 100; + + /// Make a new game. + /// + /// Give the width and the height of the game board, and where on the screen + /// the board should be located. + const fn new(width: u8, height: u8, offset: console::Position) -> Game { + Game { + board: Board::new(), + width, + height, + offset, + head: console::Position { row: 0, col: 0 }, + tail: console::Position { row: 0, col: 0 }, + direction: Direction::Up, + score: 0, + digesting: 3, + tick_interval_ms: Self::STARTING_TICK, + } + } + + /// Play a game + fn play(&mut self, stdin: &mut neotron_sdk::File, stdout: &mut neotron_sdk::File) -> u32 { + // Reset score and speed, and start with a bit of snake + self.score = 0; + self.tick_interval_ms = Self::STARTING_TICK; + self.digesting = 2; + // Wipe board + self.board.reset(); + // Add offset snake + self.head = console::Position { + row: self.height / 4, + col: self.width / 4, + }; + self.tail = self.head; + self.board.store_body(self.head, self.direction); + self.write_at(stdout, self.head, Some(Piece::Head)); + // Add random food + let pos = self.random_empty_position(); + self.board.store_food(pos); + self.write_at(stdout, pos, Some(Piece::Food)); + + 'game: loop { + // Wait for frame tick + neotron_sdk::delay(core::time::Duration::from_millis( + self.tick_interval_ms as u64, + )); + + // 1 point for not being dead + self.score += 1; + + // Read input + 'input: loop { + let mut buffer = [0u8; 1]; + if let Ok(1) = stdin.read(&mut buffer) { + match buffer[0] { + b'w' | b'W' => { + // Going up + if self.direction.is_horizontal() { + self.direction = Direction::Up; + } + } + b's' | b'S' => { + // Going down + if self.direction.is_horizontal() { + self.direction = Direction::Down; + } + } + b'a' | b'A' => { + // Going left + if self.direction.is_vertical() { + self.direction = Direction::Left; + } + } + b'd' | b'D' => { + // Going right + if self.direction.is_vertical() { + self.direction = Direction::Right; + } + } + b'q' | b'Q' => { + // Quit game + break 'game; + } + _ => { + // ignore + } + } + } else { + break 'input; + } + } + + // Mark which way we're going in the old head position + self.board.store_body(self.head, self.direction); + self.write_at(stdout, self.head, Some(Piece::Body)); + + // Update head position + match self.direction { + Direction::Up => { + if self.head.row == 0 { + break 'game; + } + self.head.row -= 1; + } + Direction::Down => { + if self.head.row == self.height - 1 { + break 'game; + } + self.head.row += 1; + } + Direction::Left => { + if self.head.col == 0 { + break 'game; + } + self.head.col -= 1; + } + Direction::Right => { + if self.head.col == self.width - 1 { + break 'game; + } + self.head.col += 1; + } + } + + // Check what we just ate + // - Food => get longer + // - Ourselves => die + if self.board.is_food(self.head) { + // yum + self.score += 10; + self.digesting = 2; + // Drop 10% on the tick interval + self.tick_interval_ms *= 9; + self.tick_interval_ms /= 10; + if self.tick_interval_ms < 5 { + // Maximum speed + self.tick_interval_ms = 5; + } + // Add random food + let pos = self.random_empty_position(); + self.board.store_food(pos); + self.write_at(stdout, pos, Some(Piece::Food)); + } else if self.board.is_body(self.head) { + // oh no + break 'game; + } + + // Write the new head + self.board.store_body(self.head, self.direction); + self.write_at(stdout, self.head, Some(Piece::Head)); + + if self.digesting == 0 { + let old_tail = self.tail; + match self.board.remove_piece(self.tail) { + Some(Direction::Up) => { + self.tail.row -= 1; + } + Some(Direction::Down) => { + self.tail.row += 1; + } + Some(Direction::Left) => { + self.tail.col -= 1; + } + Some(Direction::Right) => { + self.tail.col += 1; + } + None => { + panic!("Bad game state"); + } + } + self.write_at(stdout, old_tail, None); + } else { + self.digesting -= 1; + } + } + + self.score + } + + /// Draw a piece on the ANSI console at the given location + fn write_at( + &self, + console: &mut neotron_sdk::File, + position: console::Position, + piece: Option, + ) { + let adjusted_position = console::Position { + row: position.row + self.offset.row, + col: position.col + self.offset.col, + }; + console::move_cursor(console, adjusted_position); + if let Some(piece) = piece { + let colour = piece.get_colour(); + let ch = piece.get_char(); + console::set_sgr(console, [colour]); + let _ = console.write_char(ch); + } else { + let _ = console.write_char(' '); + } + } + + /// Find a spot on the board that is empty + fn random_empty_position(&mut self) -> console::Position { + loop { + // This isn't equally distributed. I don't really care. + let pos = console::Position { + row: (neotron_sdk::rand() % self.height as u16) as u8, + col: (neotron_sdk::rand() % self.width as u16) as u8, + }; + if self.board.is_empty(pos) { + return pos; + } + } + } +} + +/// A direction in which a body piece can face +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum Direction { + /// Facing up + Up, + /// Facing down + Down, + /// Facing left + Left, + /// Facing right + Right, +} + +impl Direction { + /// Is this left/right? + fn is_horizontal(self) -> bool { + self == Direction::Left || self == Direction::Right + } + + /// Is this up/down? + fn is_vertical(self) -> bool { + self == Direction::Up || self == Direction::Down + } +} + +/// Something we can put on a board. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[repr(u8)] +enum BoardPiece { + /// Nothing here + Empty, + /// A body, and the next piece is up + Up, + /// A body, and the next piece is down + Down, + /// A body, and the next piece is left + Left, + /// A body, and the next piece is right + Right, + /// A piece of food + Food, +} + +/// Tracks where the snake is in 2D space. +/// +/// We do this rather than maintain a Vec of body positions and a Vec of food +/// positions because it's fixed size and faster to see if a space is empty, or +/// body, or food. +struct Board { + cells: [[BoardPiece; WIDTH]; HEIGHT], +} + +impl Board { + /// Make a new empty board + const fn new() -> Board { + Board { + cells: [[BoardPiece::Empty; WIDTH]; HEIGHT], + } + } + + /// Clean up the board so everything is empty. + fn reset(&mut self) { + for y in 0..HEIGHT { + for x in 0..WIDTH { + self.cells[y][x] = BoardPiece::Empty; + } + } + } + + /// Store a body piece on the board, based on which way it is facing + fn store_body(&mut self, position: console::Position, direction: Direction) { + self.cells[usize::from(position.row)][usize::from(position.col)] = match direction { + Direction::Up => BoardPiece::Up, + Direction::Down => BoardPiece::Down, + Direction::Left => BoardPiece::Left, + Direction::Right => BoardPiece::Right, + } + } + + /// Put some food on the board + fn store_food(&mut self, position: console::Position) { + self.cells[usize::from(position.row)][usize::from(position.col)] = BoardPiece::Food; + } + + /// Is there food on the board here? + fn is_food(&mut self, position: console::Position) -> bool { + self.cells[usize::from(position.row)][usize::from(position.col)] == BoardPiece::Food + } + + /// Is there body on the board here? + fn is_body(&mut self, position: console::Position) -> bool { + let cell = self.cells[usize::from(position.row)][usize::from(position.col)]; + cell == BoardPiece::Up + || cell == BoardPiece::Down + || cell == BoardPiece::Left + || cell == BoardPiece::Right + } + + /// Is this position empty? + fn is_empty(&mut self, position: console::Position) -> bool { + self.cells[usize::from(position.row)][usize::from(position.col)] == BoardPiece::Empty + } + + /// Remove a piece from the board + fn remove_piece(&mut self, position: console::Position) -> Option { + let old = match self.cells[usize::from(position.row)][usize::from(position.col)] { + BoardPiece::Up => Some(Direction::Up), + BoardPiece::Down => Some(Direction::Down), + BoardPiece::Left => Some(Direction::Left), + BoardPiece::Right => Some(Direction::Right), + _ => None, + }; + self.cells[usize::from(position.row)][usize::from(position.col)] = BoardPiece::Empty; + old + } +} diff --git a/utilities/snake/src/main.rs b/utilities/snake/src/main.rs new file mode 100644 index 0000000..70a974e --- /dev/null +++ b/utilities/snake/src/main.rs @@ -0,0 +1,15 @@ +#![cfg_attr(target_os = "none", no_std)] +#![cfg_attr(target_os = "none", no_main)] + +#[cfg(not(target_os = "none"))] +fn main() { + neotron_sdk::init(); +} + +static mut APP: snake::App = snake::App::new(80, 25); + +#[no_mangle] +extern "C" fn neotron_main() -> i32 { + unsafe { APP.play() } + 0 +}