From 32a62cfcb925225359a5c90761b795f2f737ebd2 Mon Sep 17 00:00:00 2001 From: LLLL Colonq Date: Tue, 18 Aug 2026 00:08:28 -0400 Subject: Re-populate games --- 2026/games/asrael_io/source/src/main.rs | 642 ++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 2026/games/asrael_io/source/src/main.rs (limited to '2026/games/asrael_io/source/src/main.rs') diff --git a/2026/games/asrael_io/source/src/main.rs b/2026/games/asrael_io/source/src/main.rs new file mode 100644 index 0000000..7e57f8b --- /dev/null +++ b/2026/games/asrael_io/source/src/main.rs @@ -0,0 +1,642 @@ +mod anim; +mod bullet; +mod color; +mod enemy; +mod gfx; +mod math; +mod player; +mod rng; +mod sfx; +mod sprite; +mod starfield; + +use anim::Anim; +use bullet::Bullet; +use color::Palette; +use color::db32::{BLACK, CYAN, LIGHT_RED, LIME, RED, WHITE, YELLOW}; +use enemy::Enemy; +use math::aabb; +use player::Player; +use rng::Rng; +use sfx::Sfx; +use sprite::Sprite; +use starfield::Starfield; + +use std::f32::consts::TAU; +use std::num::NonZeroU32; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use aseprite::AsepriteFile; +use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_10X20}; +use glam::{IVec2, Vec2}; +use softbuffer::{Context, Surface}; +use web_time::Instant; +use winit::application::ApplicationHandler; +use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{Window, WindowId}; + +const MAX_FRAME: Duration = Duration::from_millis(100); +const TICK: Duration = Duration::from_nanos(16_666_667); + +const PLAYER_MOVES: [(Action, IVec2); 4] = [ + (Action::Up, IVec2::new(0, -1)), + (Action::Down, IVec2::new(0, 1)), + (Action::Left, IVec2::new(-1, 0)), + (Action::Right, IVec2::new(1, 0)), +]; + +pub(crate) const GAME_W: u32 = 240; +pub(crate) const GAME_H: u32 = 160; + +const SPRITES: &[u8] = include_bytes!("../assets/sprites.aseprite"); + +const DONE_HOLD: u32 = 120; + +const WIN_CYCLE: [u8; 4] = [LIME, WHITE, CYAN, YELLOW]; +const OVER_CYCLE: [u8; 2] = [RED, LIGHT_RED]; + +#[derive(Default)] +struct Cj2k26 { + accumulator: Duration, + anims: Vec, + cursor: Option, + difficulty: f32, + dive_timer: u32, + done_timer: u32, + enemies: Vec, + enemy_bullets: Vec, + explosion_frames: Rc>, + frame: Vec, + input: u32, + interacted: bool, + last: Option, + state: State, + palette: Palette, + player: Player, + player_bullets: Vec, + rng: Rng, + sfx: Option, + starfield: Starfield, + surface: Option, Arc>>, + tick: u32, + volley_timer: u32, + window: Option>, +} + +#[derive(Clone, Copy)] +enum Action { + Up, + Down, + Left, + Right, + Fire, +} + +#[derive(Default)] +enum State { + #[default] + Waiting, + Playing, + Win, + GameOver, +} + +#[cfg(target_arch = "wasm32")] +mod jam { + use wasm_bindgen::prelude::wasm_bindgen; + + #[wasm_bindgen] + extern "C" { + #[wasm_bindgen(js_name = jamShouldStart)] + pub fn should_start() -> bool; + + #[wasm_bindgen(js_name = jamGetDifficulty)] + pub fn difficulty() -> f32; + + #[wasm_bindgen(js_name = jamStarted)] + pub fn started(verb: &str); + + #[wasm_bindgen(js_name = jamDone)] + pub fn done(win: bool); + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod jam { + pub fn should_start() -> bool { + true + } + + pub fn difficulty() -> f32 { + 0.0 + } + + pub fn started(_verb: &str) {} + + pub fn done(_win: bool) {} +} + +impl Cj2k26 { + fn has_action(&self, action: Action) -> bool { + self.input & (1 << action as u32) != 0 + } + + fn unlock_audio(&mut self) { + if let Some(sfx) = &mut self.sfx { + sfx.resume(); + } + } + + fn update(&mut self) { + self.tick = self.tick.wrapping_add(1); + + if matches!(self.state, State::Waiting) && jam::should_start() { + jam::started("Shoot!"); + self.difficulty = jam::difficulty().min(7.0); + self.state = State::Playing; + } + + if self.input != 0 { + self.interacted = true; + } + + let playing = matches!(self.state, State::Playing); + + self.starfield.update(); + + if playing { + let player_dir = PLAYER_MOVES + .iter() + .filter(|(a, _)| self.has_action(*a)) + .fold(IVec2::ZERO, |acc, (_, v)| acc + *v); + + if player_dir != IVec2::ZERO { + self.cursor = None; + } + + let mut mouse = Vec2::ZERO; + if let Some(cursor) = self.cursor { + let target = cursor - self.player.size() / 2.0; + mouse = target - self.player.pos(); + } + + self.player.update(player_dir, mouse); + } + + let player_center = self.player.pos() + self.player.size() / 2.0; + + if playing && self.interacted && !self.enemies.is_empty() { + let dive_after = (90.0 - self.difficulty * 6.0).max(45.0) as u32; + self.dive_timer += 1; + if self.dive_timer >= dive_after { + self.dive_timer = 0; + + let i = self.rng.range(self.enemies.len() as u32) as usize; + let shots = (2.0 + self.difficulty / 4.0).min(5.0) as u32; + self.enemies[i].start_dive(player_center.x, shots, &mut self.rng); + } + + let volley_after = (70.0 - self.difficulty * 4.0).max(30.0) as u32; + self.volley_timer += 1; + if self.volley_timer >= volley_after { + self.volley_timer = 0; + + let volley = (2.0 + self.difficulty / 3.0).min(5.0) as u32; + for _ in 0..volley { + let i = self.rng.range(self.enemies.len() as u32) as usize; + let enemy = &self.enemies[i]; + let muzzle = enemy.center() + Vec2::new(0.0, enemy.size().y / 2.0); + let spread = Vec2::new((self.rng.f32() - 0.5) * 60.0, 0.0); + + self.enemy_bullets + .push(Bullet::aimed(muzzle, player_center + spread)); + } + + if let Some(sfx) = &self.sfx { + sfx.enemy_shoot(); + } + } + } + + let seconds = self.tick as f32 / 60.0; + let sway = 12.0 * (seconds * TAU / 3.0).sin(); + for enemy in &mut self.enemies { + if let Some(muzzle_pos) = enemy.update(sway, player_center) + && playing + { + self.enemy_bullets + .push(Bullet::aimed(muzzle_pos, self.player.pos())); + if let Some(sfx) = &self.sfx { + sfx.enemy_shoot(); + } + } + } + + if playing + && self.has_action(Action::Fire) + && let Some(muzzle_pos) = self.player.try_fire() + { + if let Some(sfx) = &self.sfx { + sfx.shoot(); + } + + self.player_bullets.push(Bullet::fired(muzzle_pos)); + } + + for bullet in self + .player_bullets + .iter_mut() + .chain(&mut self.enemy_bullets) + { + bullet.update(); + } + + self.player_bullets.retain(|b| !b.offscreen()); + self.enemy_bullets.retain(|b| !b.offscreen()); + + self.player_bullets.retain(|b| { + match self.enemies.iter().position(|e| b.hits(e.pos(), e.size())) { + Some(i) => { + if self.enemies[i].damage() { + let enemy = self.enemies.swap_remove(i); + self.anims + .push(Anim::new(self.explosion_frames.clone(), enemy.center())); + if let Some(sfx) = &self.sfx { + sfx.explode(); + } + } else if let Some(sfx) = &self.sfx { + sfx.hit(); + } + false + } + None => true, + } + }); + + if playing { + let p_pos = self.player.pos(); + let p_size = self.player.size(); + + let mut player_hit = false; + self.enemy_bullets.retain(|b| { + !b.hits(p_pos, p_size) || { + player_hit = true; + false + } + }); + + player_hit |= self + .enemies + .iter() + .any(|e| aabb(p_pos, p_size, e.pos(), e.size())); + + if player_hit { + self.state = State::GameOver; + self.done_timer = DONE_HOLD; + self.anims.push(Anim::new( + self.explosion_frames.clone(), + p_pos + p_size / 2.0, + )); + if let Some(sfx) = &self.sfx { + sfx.explode(); + sfx.lose(); + } + } + } + + if matches!(self.state, State::Playing) && self.enemies.is_empty() { + self.state = State::Win; + self.done_timer = DONE_HOLD; + if let Some(sfx) = &self.sfx { + sfx.win(); + } + } + + if matches!(self.state, State::Win) { + self.player + .win_anim(DONE_HOLD.saturating_sub(self.done_timer)); + } + + if self.done_timer > 0 { + self.done_timer -= 1; + if self.done_timer == 0 { + jam::done(matches!(self.state, State::Win)); + } + } + + for anim in &mut self.anims { + anim.update(); + } + self.anims.retain(|a| !a.done()); + } + + fn draw(&mut self, a: f32) { + self.frame.fill(self.palette.at(BLACK)); + + self.starfield.draw(&mut self.frame, &self.palette, a); + + if !matches!(self.state, State::GameOver) { + self.player + .draw(&mut self.frame, &self.palette, a, self.tick); + } + + for enemy in &self.enemies { + enemy.draw(&mut self.frame, &self.palette, a, self.tick); + } + + for bullet in self.player_bullets.iter().chain(&self.enemy_bullets) { + bullet.draw(&mut self.frame, &self.palette, a); + } + + for anim in &self.anims { + anim.draw(&mut self.frame, &self.palette); + } + + let cycle = (self.tick / 8) as usize; + let banner = match self.state { + State::Win => Some(("YOU WIN", WIN_CYCLE[cycle % WIN_CYCLE.len()])), + State::GameOver => Some(("GAME OVER", OVER_CYCLE[cycle % OVER_CYCLE.len()])), + _ => None, + }; + + if let Some((text, color)) = banner { + let w = text.len() as i32 * FONT_10X20.character_size.width as i32; + let pos = IVec2::new((GAME_W as i32 - w) / 2, (GAME_H as i32 - 20) / 2); + gfx::draw_text( + &mut self.frame, + &FONT_10X20, + text, + pos, + self.palette.at(color), + ); + } + + if !self.interacted && matches!(self.state, State::Waiting | State::Playing) { + let lines = ["Mouse or WASD to Move", "Left Click or Space to Fire"]; + for (i, hint) in lines.iter().enumerate() { + let w = hint.len() as i32 * FONT_6X10.character_size.width as i32; + let y = GAME_H as i32 / 2 + 20 + i as i32 * 12; + let pos = IVec2::new((GAME_W as i32 - w) / 2, y); + gfx::draw_text( + &mut self.frame, + &FONT_6X10, + hint, + pos, + self.palette.at(WHITE), + ); + } + } + + self.present(); + } + + fn present(&mut self) { + let Some(window) = self.window.as_ref() else { + return; + }; + let Some(surface) = self.surface.as_mut() else { + return; + }; + + let size = window.inner_size(); + let (w, h) = (size.width as usize, size.height as usize); + let scale = (w / GAME_W as usize).min(h / GAME_H as usize); + if scale == 0 { + return; + } + + let Ok(mut buffer) = surface.buffer_mut() else { + return; + }; + if buffer.len() != w * h { + return; + } + let out_w = GAME_W as usize * scale; + let x0 = w.saturating_sub(out_w) / 2; + let y0 = h.saturating_sub(GAME_H as usize * scale) / 2; + + buffer.fill(0); + + let mut row = vec![0u32; out_w]; + for gy in 0..GAME_H as usize { + let src = &self.frame[gy * GAME_W as usize..(gy + 1) * GAME_W as usize]; + for (i, &c) in src.iter().enumerate() { + row[i * scale..(i + 1) * scale].fill(c); + } + + for sy in 0..scale { + let start = (y0 + gy * scale + sy) * w + x0; + buffer[start..start + out_w].copy_from_slice(&row); + } + } + + let _ = buffer.present(); + } +} + +impl ApplicationHandler for Cj2k26 { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let attributes = Window::default_attributes().with_title("cj2k26"); + + #[cfg(not(target_arch = "wasm32"))] + let attributes = { + use winit::dpi::LogicalSize; + + let size = LogicalSize::new(GAME_W * 4, GAME_H * 4); + attributes.with_inner_size(size).with_min_inner_size(size) + }; + + #[cfg(target_arch = "wasm32")] + let attributes = { + use winit::platform::web::WindowAttributesExtWebSys; + attributes.with_append(true) + }; + + let window = Arc::new( + event_loop + .create_window(attributes) + .expect("failed to create window"), + ); + window.set_cursor_visible(false); + + #[cfg(not(target_arch = "wasm32"))] + { + use winit::window::CursorGrabMode; + let _ = window + .set_cursor_grab(CursorGrabMode::Confined) + .or_else(|_| window.set_cursor_grab(CursorGrabMode::Locked)); + } + + let context = Context::new(window.clone()).expect("failed to create softbuffer context"); + let mut surface = Surface::new(&context, window.clone()).expect("failed to create surface"); + + let inner = window.inner_size(); + if let (Some(w), Some(h)) = (NonZeroU32::new(inner.width), NonZeroU32::new(inner.height)) { + let _ = surface.resize(w, h); + } + + self.frame = vec![0; (GAME_W * GAME_H) as usize]; + self.surface = Some(surface); + + let sprites = AsepriteFile::from_reader(SPRITES).expect("failed to read sprites aseprite!"); + + self.enemies = (0..8) + .map(|i| { + let hp = if i % 3 == 0 { 2 } else { 1 }; + Enemy::new( + &sprites, + "enemy", + Vec2::new(24.0 + i as f32 * 24.0, 24.0), + hp, + ) + }) + .collect(); + + self.explosion_frames = Rc::new(Sprite::frames_from_ase(&sprites, "explosion")); + self.palette = Palette::from_ase(sprites.palette()); + self.player = Player::new(&sprites, 2.0); + self.rng = Rng::default(); + self.sfx = Some(Sfx::new()); + self.starfield = Starfield::new(60, &mut self.rng); + self.window = Some(window.clone()); + + window.request_redraw(); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => event_loop.exit(), + + WindowEvent::Resized(size) => { + if let Some(surface) = self.surface.as_mut() + && let (Some(w), Some(h)) = + (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) + { + let _ = surface.resize(w, h); + } + } + + WindowEvent::RedrawRequested => { + let now = Instant::now(); + let dt = self + .last + .replace(now) + .map(|prev| (now - prev).min(MAX_FRAME)) + .unwrap_or_default(); + self.accumulator += dt; + + while self.accumulator >= TICK { + self.update(); + self.accumulator -= TICK; + } + + let alpha = self.accumulator.as_secs_f32() / TICK.as_secs_f32(); + self.draw(alpha); + + #[cfg(not(target_arch = "wasm32"))] + std::thread::sleep(TICK.saturating_sub(now.elapsed())); + + if let Some(window) = self.window.as_ref() { + window.request_redraw(); + } + } + + WindowEvent::KeyboardInput { + event: + KeyEvent { + physical_key: PhysicalKey::Code(code), + state, + .. + }, + .. + } => { + self.unlock_audio(); + + if let Some(action) = bind_key(code) { + let bit = 1 << action as u32; + + match state { + ElementState::Pressed => self.input |= bit, + ElementState::Released => self.input &= !bit, + } + } + } + + WindowEvent::CursorMoved { position, .. } => { + if let Some(window) = self.window.as_ref() { + let size = window.inner_size(); + let (w, h) = (size.width as i32, size.height as i32); + let scale = (w / GAME_W as i32).min(h / GAME_H as i32); + + if scale > 0 { + let x0 = (w - GAME_W as i32 * scale) / 2; + let y0 = (h - GAME_H as i32 * scale) / 2; + let gx = (position.x as i32 - x0) as f32 / scale as f32; + let gy = (position.y as i32 - y0) as f32 / scale as f32; + + self.cursor = Some(Vec2::new(gx, gy)); + if matches!(self.state, State::Playing) { + self.interacted = true; + } + } + } + } + + WindowEvent::MouseInput { + state, + button: MouseButton::Left, + .. + } => { + self.unlock_audio(); + + let bit = 1 << Action::Fire as u32; + match state { + ElementState::Pressed => self.input |= bit, + ElementState::Released => self.input &= !bit, + } + } + + WindowEvent::MouseInput { .. } | WindowEvent::KeyboardInput { .. } => { + self.unlock_audio(); + } + + _ => {} + } + } +} + +fn bind_key(code: KeyCode) -> Option { + match code { + KeyCode::KeyW | KeyCode::ArrowUp => Some(Action::Up), + KeyCode::KeyS | KeyCode::ArrowDown => Some(Action::Down), + KeyCode::KeyA | KeyCode::ArrowLeft => Some(Action::Left), + KeyCode::KeyD | KeyCode::ArrowRight => Some(Action::Right), + KeyCode::Space => Some(Action::Fire), + + _ => None, + } +} + +fn main() { + let event_loop = EventLoop::new().expect("failed to create event loop"); + + #[cfg(not(target_arch = "wasm32"))] + { + env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); + event_loop + .run_app(&mut Cj2k26::default()) + .expect("failed to run app"); + } + + #[cfg(target_arch = "wasm32")] + { + use winit::platform::web::EventLoopExtWebSys; + + console_error_panic_hook::set_once(); + event_loop.spawn_app(Cj2k26::default()); + } +} -- cgit v1.3.1