diff options
| author | LLLL Colonq <llll@colonq> | 2026-07-31 05:00:49 -0400 |
|---|---|---|
| committer | LLLL Colonq <llll@colonq> | 2026-07-31 05:00:49 -0400 |
| commit | ea5332e4a1a035e888709bee278fcafb182f7922 (patch) | |
| tree | 7e1520391e7f04fa3df425fd9ed02008f748cdfd /2026/games_submissions/asrael_io/source/src | |
| parent | 3ec03ba57474aa4320cb7901980b55706adbc111 (diff) | |
Update
Diffstat (limited to '2026/games_submissions/asrael_io/source/src')
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/anim.rs | 42 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/bullet.rs | 118 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/color.rs | 66 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/enemy.rs | 174 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/gfx.rs | 113 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/main.rs | 642 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/math.rs | 13 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/player.rs | 96 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/rng.rs | 35 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/sfx.rs | 302 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/sprite.rs | 89 | ||||
| -rw-r--r-- | 2026/games_submissions/asrael_io/source/src/starfield.rs | 59 |
12 files changed, 1749 insertions, 0 deletions
diff --git a/2026/games_submissions/asrael_io/source/src/anim.rs b/2026/games_submissions/asrael_io/source/src/anim.rs new file mode 100644 index 0000000..427e22e --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/anim.rs @@ -0,0 +1,42 @@ +use crate::color::Palette; +use crate::sprite::Sprite; + +use std::rc::Rc; + +use glam::Vec2; + +const FRAME_TICKS: u32 = 6; + +pub struct Anim { + center: Vec2, + frames: Rc<Vec<Sprite>>, + tick: u32, +} + +impl Anim { + pub fn new(frames: Rc<Vec<Sprite>>, center: Vec2) -> Self { + Self { + center, + frames, + tick: 0, + } + } + + pub fn done(&self) -> bool { + self.tick >= FRAME_TICKS * self.frames.len() as u32 + } + + pub fn update(&mut self) { + self.tick += 1; + } + + pub fn draw(&self, frame: &mut [u32], palette: &Palette) { + let i = (self.tick / FRAME_TICKS) as usize; + let Some(sprite) = self.frames.get(i) else { + return; + }; + + let pos = self.center - sprite.size.as_vec2() / 2.0; + sprite.draw_at(frame, palette, pos.round().as_ivec2()); + } +} diff --git a/2026/games_submissions/asrael_io/source/src/bullet.rs b/2026/games_submissions/asrael_io/source/src/bullet.rs new file mode 100644 index 0000000..ac44f25 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/bullet.rs @@ -0,0 +1,118 @@ +use crate::color::Palette; +use crate::color::db32::{LIGHT_RED, LIME}; +use crate::math::aabb; +use crate::sprite::Sprite; +use crate::{GAME_H, GAME_W}; + +use glam::Vec2; + +const ENEMY_BULLET_W: i32 = 2; +const ENEMY_BULLET_H: i32 = 6; +const PLAYER_BULLET_W: i32 = 3; +const PLAYER_BULLET_H: i32 = 6; +const ENEMY_SHOT_SPEED: f32 = 1.5; +const PLAYER_SHOT_SPEED: f32 = 4.0; + +#[rustfmt::skip] +const ENEMY_BULLET_PIXELS: [u8; (ENEMY_BULLET_W * ENEMY_BULLET_H) as usize] = [ + LIME, LIME, + LIME, LIME, + LIME, LIME, + LIME, LIME, + LIME, LIME, + LIME, LIME, +]; + +#[rustfmt::skip] +const PLAYER_BULLET_PIXELS: [u8; (PLAYER_BULLET_W * PLAYER_BULLET_H) as usize] = [ + 0, LIGHT_RED, 0, + LIGHT_RED, LIGHT_RED, LIGHT_RED, + 0, 0, 0, + 0, LIGHT_RED, 0, + 0, LIGHT_RED, 0, + 0, LIGHT_RED, 0, +]; + +pub struct Bullet { + angle: Option<f32>, + pos: Vec2, + vel: Vec2, + sprite: Sprite, +} + +impl Bullet { + fn new( + pos: Vec2, + vel: Vec2, + angle: Option<f32>, + width: i32, + height: i32, + pixels: &[u8], + ) -> Self { + let sprite = Sprite::new(width, height, pixels.to_vec()); + + Self { + angle, + pos, + vel, + sprite, + } + } + + pub fn aimed(from: Vec2, target: Vec2) -> Self { + let dir = (target - from).normalize_or_zero(); + let vel = dir * ENEMY_SHOT_SPEED; + let pos = from + Vec2::new(-(ENEMY_BULLET_W as f32) / 2.0, 0.0); + let angle = (-dir.x).atan2(dir.y); + + Self::new( + pos, + vel, + Some(angle), + ENEMY_BULLET_W, + ENEMY_BULLET_H, + &ENEMY_BULLET_PIXELS, + ) + } + + pub fn fired(muzzle_pos: Vec2) -> Self { + let pos = + muzzle_pos + Vec2::new(-(PLAYER_BULLET_W as f32) / 2.0, -(PLAYER_BULLET_H as f32)); + + Self::new( + pos, + Vec2::new(0.0, -PLAYER_SHOT_SPEED), + None, + PLAYER_BULLET_W, + PLAYER_BULLET_H, + &PLAYER_BULLET_PIXELS, + ) + } + + pub fn hits(&self, pos: Vec2, size: Vec2) -> bool { + aabb(self.pos, self.sprite.size.as_vec2(), pos, size) + } + + pub fn offscreen(&self) -> bool { + self.pos.x + self.sprite.size.x as f32 <= 0.0 + || self.pos.x >= GAME_W as f32 + || self.pos.y + self.sprite.size.y as f32 <= 0.0 + || self.pos.y >= GAME_H as f32 + } + + pub fn update(&mut self) { + self.pos += self.vel; + } + + pub fn draw(&self, frame: &mut [u32], palette: &Palette, a: f32) { + let p = self.pos - self.vel * (1.0 - a); + + match self.angle { + Some(angle) => { + let center = p + self.sprite.size.as_vec2() / 2.0; + self.sprite.draw_rotated(frame, palette, center, angle, 0); + } + None => self.sprite.draw_at(frame, palette, p.round().as_ivec2()), + } + } +} diff --git a/2026/games_submissions/asrael_io/source/src/color.rs b/2026/games_submissions/asrael_io/source/src/color.rs new file mode 100644 index 0000000..1db0786 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/color.rs @@ -0,0 +1,66 @@ +pub type Color = u32; + +#[allow(dead_code)] +pub mod db32 { + pub const BLACK: u8 = 0; + pub const DARK_PURPLE: u8 = 1; + pub const MAROON: u8 = 2; + pub const DARK_BROWN: u8 = 3; + pub const BROWN: u8 = 4; + pub const ORANGE: u8 = 5; + pub const TAN: u8 = 6; + pub const PEACH: u8 = 7; + pub const YELLOW: u8 = 8; + pub const LIME: u8 = 9; + pub const GREEN: u8 = 10; + pub const SEA_GREEN: u8 = 11; + pub const DARK_GREEN: u8 = 12; + pub const OLIVE: u8 = 13; + pub const CHARCOAL: u8 = 14; + pub const NAVY: u8 = 15; + pub const STEEL_BLUE: u8 = 16; + pub const INDIGO: u8 = 17; + pub const BLUE: u8 = 18; + pub const CYAN: u8 = 19; + pub const PALE_BLUE: u8 = 20; + pub const WHITE: u8 = 21; + pub const GRAY: u8 = 22; + pub const STONE: u8 = 23; + pub const DIM_GRAY: u8 = 24; + pub const SLATE: u8 = 25; + pub const PURPLE: u8 = 26; + pub const RED: u8 = 27; + pub const LIGHT_RED: u8 = 28; + pub const PINK: u8 = 29; + pub const MOSS: u8 = 30; + pub const KHAKI: u8 = 31; +} + +const fn from_rgb(r: u8, g: u8, b: u8) -> Color { + (r as u32) << 16 | (g as u32) << 8 | b as u32 +} + +#[derive(Clone, Copy)] +pub struct Palette([Color; 256]); + +impl Default for Palette { + fn default() -> Self { + Self([0; 256]) + } +} + +impl Palette { + pub fn at(&self, index: u8) -> Color { + self.0[index as usize] + } + + pub fn from_ase(src: &[aseprite::Color]) -> Self { + let mut out = [0u32; 256]; + + for (dst, s) in out.iter_mut().zip(src) { + *dst = from_rgb(s.r, s.g, s.b); + } + + Palette(out) + } +} diff --git a/2026/games_submissions/asrael_io/source/src/enemy.rs b/2026/games_submissions/asrael_io/source/src/enemy.rs new file mode 100644 index 0000000..46eabc0 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/enemy.rs @@ -0,0 +1,174 @@ +use crate::color::Palette; +use crate::color::db32::{LIGHT_RED, PINK, PURPLE}; +use crate::gfx; +use crate::math; +use crate::rng::Rng; +use crate::sprite::Sprite; +use crate::{GAME_H, GAME_W}; + +use std::f32::consts::TAU; + +use aseprite::AsepriteFile; +use glam::Vec2; + +const ROT_STEPS: f32 = 16.0; + +#[derive(Default)] +enum State { + Dive { + path: [Vec2; 4], + fired: u32, + shots: u32, + t: f32, + }, + #[default] + Formation, +} + +pub struct Enemy { + base: Vec2, + flash: u32, + hp: u32, + pos: Vec2, + step: Vec2, + sprite: Sprite, + state: State, +} + +impl Enemy { + pub fn new(sprites: &AsepriteFile, layer: &str, base: Vec2, hp: u32) -> Self { + let pos = base; + let step = Vec2::ZERO; + let sprite = Sprite::from_ase(sprites, layer); + let state = State::default(); + + Self { + base, + flash: 0, + hp, + pos, + step, + sprite, + state, + } + } + + pub fn damage(&mut self) -> bool { + self.hp = self.hp.saturating_sub(1); + self.flash = 12; + self.hp == 0 + } + + pub fn center(&self) -> Vec2 { + self.pos + self.size() / 2.0 + } + + pub fn pos(&self) -> Vec2 { + self.pos + } + + pub fn size(&self) -> Vec2 { + self.sprite.size.as_vec2() + } + + pub fn start_dive(&mut self, player_x: f32, shots: u32, rng: &mut Rng) { + if matches!(self.state, State::Dive { .. }) { + return; + } + + let side = if self.pos.x < 70.0 { + 1.0 + } else if self.pos.x > GAME_W as f32 - 70.0 { + -1.0 + } else if rng.chance(0.5) { + 1.0 + } else { + -1.0 + }; + + let min = Vec2::new(4.0, 4.0); + let max = Vec2::new(GAME_W as f32 - 20.0, GAME_H as f32 - 20.0); + let swing = (self.pos + Vec2::new(side * 70.0, -40.0)).clamp(min, max); + let plunge = Vec2::new(player_x, GAME_H as f32 - 20.0).clamp(min, max); + + let path = [self.pos, swing, plunge, self.base]; + + self.state = State::Dive { + path, + fired: 0, + shots, + t: 0.0, + }; + } + + pub fn update(&mut self, sway: f32, player: Vec2) -> Option<Vec2> { + let before = self.pos; + let mut shot = None; + + self.flash = self.flash.saturating_sub(1); + + match &mut self.state { + State::Formation => { + let target = self.base.x + sway; + self.pos.x += (target - self.pos.x) * 0.2; + } + + State::Dive { + path, + fired, + shots, + t, + } => { + *t += 1.0 / 240.0; + + if *t >= 1.0 { + self.pos = self.base; + self.state = State::Formation; + } else { + self.pos = math::bezier(*path, *t); + + let center = self.pos + self.sprite.size.as_vec2() / 2.0; + let facing = self.step.normalize_or_zero(); + let to_player = (player - center).normalize_or_zero(); + + let next = 0.25 + 0.5 * *fired as f32 / *shots as f32; + if *fired < *shots && *t > next && facing.dot(to_player) > 0.8 { + *fired += 1; + shot = Some(center); + } + } + } + } + + self.step = self.pos - before; + shot + } + + pub fn draw(&self, frame: &mut [u32], palette: &Palette, a: f32, tick: u32) { + let pos = self.pos - self.step * (1.0 - a); + let tint = if self.flash > 0 && (tick / 2).is_multiple_of(2) { + LIGHT_RED + } else { + 0 + }; + + if matches!(self.state, State::Dive { .. }) && self.step.length_squared() > 0.001 { + let dir = self.step.normalize(); + let angle = (-dir.x).atan2(dir.y); + let angle = (angle * ROT_STEPS / TAU).round() * (TAU / ROT_STEPS); + let center = pos + self.sprite.size.as_vec2() / 2.0; + self.sprite + .draw_rotated(frame, palette, center, angle, tint); + + let half = self.sprite.size.y as f32 / 2.0; + for i in 0..3 { + let tail = center - dir * (half + 1.0 + i as f32 * 2.0); + let c = [PURPLE, PINK][(tick / 4 + i) as usize % 2]; + gfx::blit(frame, palette, &[c], tail.round().as_ivec2(), 1, 0); + } + } else { + self.sprite + .draw_tinted(frame, palette, pos.round().as_ivec2(), tint); + } + } +} diff --git a/2026/games_submissions/asrael_io/source/src/gfx.rs b/2026/games_submissions/asrael_io/source/src/gfx.rs new file mode 100644 index 0000000..4f515c1 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/gfx.rs @@ -0,0 +1,113 @@ +use crate::color::Palette; +use crate::{GAME_H, GAME_W}; + +use embedded_graphics::Drawable; +use embedded_graphics::Pixel; +use embedded_graphics::draw_target::DrawTarget; +use embedded_graphics::geometry::{OriginDimensions, Point, Size}; +use embedded_graphics::mono_font::{MonoFont, MonoTextStyle}; +use embedded_graphics::pixelcolor::{Rgb888, RgbColor}; +use embedded_graphics::text::{Baseline, Text}; +use glam::{IVec2, Vec2}; + +pub struct Frame<'a>(pub &'a mut [u32]); + +impl OriginDimensions for Frame<'_> { + fn size(&self) -> Size { + Size::new(GAME_W, GAME_H) + } +} + +impl DrawTarget for Frame<'_> { + type Color = Rgb888; + type Error = core::convert::Infallible; + + fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator<Item = Pixel<Rgb888>>, + { + for Pixel(p, color) in pixels { + if p.x < 0 || p.x >= GAME_W as i32 || p.y < 0 || p.y >= GAME_H as i32 { + continue; + } + + self.0[(p.y * GAME_W as i32 + p.x) as usize] = + (color.r() as u32) << 16 | (color.g() as u32) << 8 | color.b() as u32; + } + + Ok(()) + } +} + +pub fn draw_text(frame: &mut [u32], font: &MonoFont, text: &str, pos: IVec2, color: u32) { + let color = Rgb888::new((color >> 16) as u8, (color >> 8) as u8, color as u8); + let style = MonoTextStyle::new(font, color); + + let _ = Text::with_baseline(text, Point::new(pos.x, pos.y), style, Baseline::Top) + .draw(&mut Frame(frame)); +} + +pub fn blit( + frame: &mut [u32], + palette: &Palette, + pixels: &[u8], + origin: IVec2, + width: i32, + tint: u8, +) { + for (i, &px) in pixels.iter().enumerate() { + if px == 0 { + continue; + } + + let sx = origin.x + i as i32 % width; + let sy = origin.y + i as i32 / width; + if sx < 0 || sx >= GAME_W as i32 || sy < 0 || sy >= GAME_H as i32 { + continue; + } + + let px = if tint != 0 { tint } else { px }; + frame[(sy * GAME_W as i32 + sx) as usize] = palette.at(px); + } +} + +pub fn blit_rotated( + frame: &mut [u32], + palette: &Palette, + pixels: &[u8], + center: Vec2, + width: i32, + angle: f32, + tint: u8, +) { + let height = pixels.len() as i32 / width; + let half = Vec2::new(width as f32, height as f32) / 2.0; + let (sin, cos) = angle.sin_cos(); + let r = half.length().ceil() as i32; + + for dy in -r..=r { + for dx in -r..=r { + let x = dx as f32 + 0.5; + let y = dy as f32 + 0.5; + let sx = (cos * x + sin * y + half.x).floor() as i32; + let sy = (-sin * x + cos * y + half.y).floor() as i32; + if sx < 0 || sx >= width || sy < 0 || sy >= height { + continue; + } + + let px = pixels[(sy * width + sx) as usize]; + if px == 0 { + continue; + } + + let fx = center.x.round() as i32 + dx; + let fy = center.y.round() as i32 + dy; + if fx < 0 || fx >= GAME_W as i32 || fy < 0 || fy >= GAME_H as i32 { + continue; + } + + let px = if tint != 0 { tint } else { px }; + frame[(fy * GAME_W as i32 + fx) as usize] = palette.at(px); + } + } +} diff --git a/2026/games_submissions/asrael_io/source/src/main.rs b/2026/games_submissions/asrael_io/source/src/main.rs new file mode 100644 index 0000000..7e57f8b --- /dev/null +++ b/2026/games_submissions/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<Anim>,
+ cursor: Option<Vec2>,
+ difficulty: f32,
+ dive_timer: u32,
+ done_timer: u32,
+ enemies: Vec<Enemy>,
+ enemy_bullets: Vec<Bullet>,
+ explosion_frames: Rc<Vec<Sprite>>,
+ frame: Vec<u32>,
+ input: u32,
+ interacted: bool,
+ last: Option<Instant>,
+ state: State,
+ palette: Palette,
+ player: Player,
+ player_bullets: Vec<Bullet>,
+ rng: Rng,
+ sfx: Option<Sfx>,
+ starfield: Starfield,
+ surface: Option<Surface<Arc<Window>, Arc<Window>>>,
+ tick: u32,
+ volley_timer: u32,
+ window: Option<Arc<Window>>,
+}
+
+#[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<Action> {
+ 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());
+ }
+}
diff --git a/2026/games_submissions/asrael_io/source/src/math.rs b/2026/games_submissions/asrael_io/source/src/math.rs new file mode 100644 index 0000000..371f165 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/math.rs @@ -0,0 +1,13 @@ +use glam::Vec2; + +pub fn aabb(a_pos: Vec2, a_size: Vec2, b_pos: Vec2, b_size: Vec2) -> bool { + a_pos.x < b_pos.x + b_size.x + && b_pos.x < a_pos.x + a_size.x + && a_pos.y < b_pos.y + b_size.y + && b_pos.y < a_pos.y + a_size.y +} + +pub fn bezier(p: [Vec2; 4], t: f32) -> Vec2 { + let u = 1.0 - t; + u * u * u * p[0] + 3.0 * u * u * t * p[1] + 3.0 * u * t * t * p[2] + t * t * t * p[3] +} diff --git a/2026/games_submissions/asrael_io/source/src/player.rs b/2026/games_submissions/asrael_io/source/src/player.rs new file mode 100644 index 0000000..637160b --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/player.rs @@ -0,0 +1,96 @@ +use crate::color::Palette; +use crate::color::db32::{ORANGE, YELLOW}; +use crate::gfx; +use crate::sprite::Sprite; +use crate::{GAME_H, GAME_W}; + +use aseprite::AsepriteFile; +use glam::{IVec2, Vec2}; + +const FIRE_COOLDOWN: u32 = 12; +const MOUSE_MAX: f32 = 2.0; + +#[derive(Default)] +pub struct Player { + cooldown: u32, + pos: Vec2, + step: Vec2, + speed: f32, + sprite: Sprite, +} + +impl Player { + pub fn new(sprites: &AsepriteFile, speed: f32) -> Self { + let cooldown = 0; + let pos = Vec2::new(GAME_W as f32 / 2.0, GAME_H as f32 / 2.0); + let step = Vec2::ZERO; + let sprite = Sprite::from_ase(sprites, "player"); + + Self { + cooldown, + pos, + step, + speed, + sprite, + } + } + + pub fn win_anim(&mut self, t: u32) { + let before = self.pos; + + if t < 40 { + self.pos.y += 0.3; + } else { + self.pos.y -= ((t - 40) as f32 * 0.25).min(8.0); + } + + self.step = self.pos - before; + } + + pub fn pos(&self) -> Vec2 { + self.pos + } + + pub fn size(&self) -> Vec2 { + self.sprite.size.as_vec2() + } + + pub fn try_fire(&mut self) -> Option<Vec2> { + (self.cooldown == 0).then(|| { + self.cooldown = FIRE_COOLDOWN; + self.pos + Vec2::new(self.sprite.size.x as f32 / 2.0, 0.0) + }) + } + + pub fn update(&mut self, direction: IVec2, mouse: Vec2) { + let before = self.pos; + + self.cooldown = self.cooldown.saturating_sub(1); + + let dir = direction.as_vec2().normalize_or_zero(); + self.pos += dir * self.speed + mouse.clamp_length_max(MOUSE_MAX); + + let w = self.sprite.size.x as f32; + let h = self.sprite.size.y as f32; + + self.pos.x = self.pos.x.clamp(0.0, GAME_W as f32 - w); + self.pos.y = self.pos.y.clamp(0.0, GAME_H as f32 - h); + self.step = self.pos - before; + } + + pub fn draw(&self, frame: &mut [u32], palette: &Palette, a: f32, tick: u32) { + let pos = (self.pos - self.step * (1.0 - a)).round().as_ivec2(); + self.sprite.draw_at(frame, palette, pos); + + if self.step.length_squared() > 0.01 { + let flame = if (tick / 4).is_multiple_of(2) { + ORANGE + } else { + YELLOW + }; + let exhaust = [flame, flame]; + let nozzle = pos + IVec2::new(self.sprite.size.x / 2, self.sprite.size.y); + gfx::blit(frame, palette, &exhaust, nozzle, 1, 0); + } + } +} diff --git a/2026/games_submissions/asrael_io/source/src/rng.rs b/2026/games_submissions/asrael_io/source/src/rng.rs new file mode 100644 index 0000000..6e46de0 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/rng.rs @@ -0,0 +1,35 @@ +pub struct Rng(u32);
+
+impl Rng {
+ pub fn new(seed: u32) -> Self {
+ let mut rng = Self(seed.max(1));
+ rng.next();
+ rng
+ }
+
+ pub fn next(&mut self) -> u32 {
+ self.0 ^= self.0 << 13;
+ self.0 ^= self.0 >> 17;
+ self.0 ^= self.0 << 5;
+
+ self.0
+ }
+
+ pub fn range(&mut self, n: u32) -> u32 {
+ self.next() % n
+ }
+
+ pub fn f32(&mut self) -> f32 {
+ self.next() as f32 / u32::MAX as f32
+ }
+
+ pub fn chance(&mut self, p: f32) -> bool {
+ self.f32() < p
+ }
+}
+
+impl Default for Rng {
+ fn default() -> Self {
+ Self::new(u32::from_be_bytes(*b"CLNK"))
+ }
+}
diff --git a/2026/games_submissions/asrael_io/source/src/sfx.rs b/2026/games_submissions/asrael_io/source/src/sfx.rs new file mode 100644 index 0000000..a1367df --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/sfx.rs @@ -0,0 +1,302 @@ +use std::sync::mpsc::{Receiver, Sender, channel}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{Device, OutputCallbackInfo, Stream, SupportedStreamConfig}; +use resid::{ChipModel, PAL_CLOCK, SamplingMethod, Sid}; + +const GATE: u8 = 0x01; +const TRIANGLE: u8 = 0x10; +const SAWTOOTH: u8 = 0x20; +const PULSE: u8 = 0x40; +const NOISE: u8 = 0x80; + +enum Sound { + EnemyShoot, + Explosion, + Hit, + Lose, + Shoot, + Win, +} + +struct Pending { + config: SupportedStreamConfig, + device: Device, + rx: Receiver<Sound>, + sid: Sid, +} + +pub struct Sfx { + pending: Option<Pending>, + stream: Option<Stream>, + tx: Sender<Sound>, +} + +impl Sfx { + pub fn new() -> Self { + let device = cpal::default_host() + .default_output_device() + .expect("no audio output device"); + + let config = device + .default_output_config() + .expect("no default audio config"); + + let mut sid = Sid::new(ChipModel::Mos6581); + let (tx, rx) = channel::<Sound>(); + + sid.set_sampling_parameters(SamplingMethod::ResampleFast, PAL_CLOCK, config.sample_rate()); + Self::set_volume(&mut sid, 0x0F); + Self::set_envelope(&mut sid, 0, 0x06, 0x00); + Self::set_envelope(&mut sid, 1, 0x06, 0x00); + Self::set_envelope(&mut sid, 2, 0x08, 0xA9); + + Self { + pending: Some(Pending { + config, + device, + rx, + sid, + }), + stream: None, + tx, + } + } + + pub fn resume(&mut self) { + if let Some(stream) = &self.stream { + let _ = stream.play(); + return; + } + + let Some(Pending { + config, + device, + rx, + mut sid, + }) = self.pending.take() + else { + return; + }; + + let channels = config.channels() as usize; + let sample_rate = config.sample_rate(); + let mut scratch: Vec<i16> = Vec::new(); + + let mut enemy_shoot_hz: f32 = 0.0; + let mut explosion_hz: f32 = 0.0; + let mut hit_t: u32 = 0; + let mut lose_hz: f32 = 0.0; + let mut lose_t: u32 = 0; + let mut lose_wob: f32 = 0.0; + let mut shoot_hz: f32 = 0.0; + let mut win_t: u32 = 0; + + let stream = device + .build_output_stream( + config.into(), + move |data: &mut [f32], _: &OutputCallbackInfo| { + while let Ok(sound) = rx.try_recv() { + match sound { + Sound::EnemyShoot => { + enemy_shoot_hz = 520.0; + Self::set_freq(&mut sid, 1, enemy_shoot_hz); + Self::gate_on(&mut sid, 1, TRIANGLE); + } + + Sound::Explosion => { + hit_t = 0; + Self::set_envelope(&mut sid, 2, 0x08, 0xA9); + + explosion_hz = 2500.0; + Self::set_freq(&mut sid, 2, explosion_hz); + Self::gate_on(&mut sid, 2, NOISE); + } + + Sound::Hit => { + Self::set_envelope(&mut sid, 2, 0x05, 0x00); + + hit_t = 45; + Self::set_freq(&mut sid, 2, 2500.0); + Self::gate_on(&mut sid, 2, NOISE); + } + + Sound::Lose => { + enemy_shoot_hz = 0.0; + Self::set_envelope(&mut sid, 1, 0x00, 0xC6); + Self::set_pulse_width(&mut sid, 1, 0x0800); + + lose_hz = 392.0; + lose_t = 300; + lose_wob = 0.0; + Self::set_freq(&mut sid, 1, lose_hz); + Self::gate_on(&mut sid, 1, PULSE); + } + + Sound::Shoot => { + shoot_hz = 1760.0; + Self::set_freq(&mut sid, 0, shoot_hz); + Self::gate_on(&mut sid, 0, SAWTOOTH); + } + + Sound::Win => { + shoot_hz = 0.0; + Self::set_envelope(&mut sid, 0, 0x00, 0xA9); + Self::set_pulse_width(&mut sid, 0, 0x0800); + + win_t = 300; + Self::set_freq(&mut sid, 0, 987.77); + Self::gate_on(&mut sid, 0, PULSE); + } + } + } + + let frames = data.len() / channels; + if scratch.len() < frames { + scratch.resize(frames, 0); + } + let out = &mut scratch[..frames]; + + for block in out.chunks_mut(64) { + Self::sweep(&mut sid, &mut shoot_hz, 0.975, 220.0, 0, SAWTOOTH); + Self::sweep(&mut sid, &mut enemy_shoot_hz, 0.97, 200.0, 1, TRIANGLE); + Self::sweep(&mut sid, &mut explosion_hz, 0.985, 150.0, 2, NOISE); + + if hit_t > 0 { + hit_t -= 1; + if hit_t == 0 { + Self::gate_off(&mut sid, 2, NOISE); + } + } + + if win_t > 0 { + win_t -= 1; + + if win_t == 245 || win_t == 190 { + let hz = if win_t == 245 { 1318.5 } else { 1661.2 }; + Self::set_freq(&mut sid, 0, hz); + Self::gate_on(&mut sid, 0, PULSE); + } + + if win_t == 0 { + Self::gate_off(&mut sid, 0, PULSE); + } + } + + if lose_t > 0 { + lose_t -= 1; + lose_hz *= 0.995; + lose_wob += 0.35; + + let hz = lose_hz * (1.0 + 0.05 * lose_wob.sin()); + Self::set_freq(&mut sid, 1, hz); + + if lose_t == 0 { + Self::gate_off(&mut sid, 1, PULSE); + } + } + + Self::fill(&mut sid, block, sample_rate); + } + + for (frame, &s) in data.chunks_mut(channels).zip(out.iter()) { + frame.fill(s as f32 / 32768.0); + } + }, + move |err| log::error!("audio stream error: {err}"), + None, + ) + .expect("failed to build audio stream"); + + stream.play().expect("failed to start audio stream"); + self.stream = Some(stream); + } + + pub fn enemy_shoot(&self) { + let _ = self.tx.send(Sound::EnemyShoot); + } + + pub fn explode(&self) { + let _ = self.tx.send(Sound::Explosion); + } + + pub fn hit(&self) { + let _ = self.tx.send(Sound::Hit); + } + + pub fn lose(&self) { + let _ = self.tx.send(Sound::Lose); + } + + pub fn shoot(&self) { + let _ = self.tx.send(Sound::Shoot); + } + + pub fn win(&self) { + let _ = self.tx.send(Sound::Win); + } + + fn fill(sid: &mut Sid, out: &mut [i16], sample_rate: u32) { + let budget = PAL_CLOCK / sample_rate + 1; + let mut i = 0; + + while i < out.len() { + let target = (out.len() - i) as u32; + let (n, _) = sid.sample(target * budget, &mut out[i..], 1); + + if n == 0 { + break; + } + + i += n; + } + } + + fn gate_off(sid: &mut Sid, voice: u8, wave: u8) { + sid.write(voice * 7 + 4, wave); + } + + fn gate_on(sid: &mut Sid, voice: u8, wave: u8) { + sid.write(voice * 7 + 4, wave); + sid.write(voice * 7 + 4, wave | GATE); + } + + fn set_envelope(sid: &mut Sid, voice: u8, attack_decay: u8, sustain_release: u8) { + let base = voice * 7; + + sid.write(base + 5, attack_decay); + sid.write(base + 6, sustain_release); + } + + fn set_freq(sid: &mut Sid, voice: u8, hz: f32) { + let base = voice * 7; + let freq = (hz * 16_777_216.0 / PAL_CLOCK as f32) as u16; + + sid.write(base, freq as u8); + sid.write(base + 1, (freq >> 8) as u8); + } + + fn set_pulse_width(sid: &mut Sid, voice: u8, width: u16) { + let base = voice * 7; + + sid.write(base + 2, width as u8); + sid.write(base + 3, (width >> 8) as u8); + } + + fn set_volume(sid: &mut Sid, volume: u8) { + sid.write(0x18, volume); + } + + fn sweep(sid: &mut Sid, hz: &mut f32, rate: f32, floor: f32, voice: u8, wave: u8) { + if *hz > 0.0 { + *hz *= rate; + + if *hz < floor { + *hz = 0.0; + Self::gate_off(sid, voice, wave); + } else { + Self::set_freq(sid, voice, *hz); + } + } + } +} diff --git a/2026/games_submissions/asrael_io/source/src/sprite.rs b/2026/games_submissions/asrael_io/source/src/sprite.rs new file mode 100644 index 0000000..2bfb104 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/sprite.rs @@ -0,0 +1,89 @@ +use crate::color::Palette; +use crate::gfx; + +use aseprite::{AsepriteFile, CelKind}; +use glam::{IVec2, Vec2}; + +#[derive(Clone, Default)] +pub struct Sprite { + pixels: Vec<u8>, + pub size: IVec2, +} + +impl Sprite { + pub fn new(width: i32, height: i32, pixels: Vec<u8>) -> Self { + Self { + pixels, + size: IVec2::new(width, height), + } + } + + pub fn from_ase(file: &AsepriteFile, layer: &str) -> Self { + let index = file + .layers() + .iter() + .position(|l| l.name == layer) + .unwrap_or_else(|| panic!("no layer named {layer}")); + let cel = file.cel(file.layer_ref(index).unwrap(), 0).unwrap(); + + let (CelKind::Raw { pixels, .. } | CelKind::Compressed { pixels, .. }) = &cel.kind else { + panic!("layer {layer} has no pixel cel"); + }; + + Self::new( + pixels.width as i32, + pixels.height as i32, + pixels.data.clone(), + ) + } + + pub fn frames_from_ase(file: &AsepriteFile, layer: &str) -> Vec<Self> { + let index = file + .layers() + .iter() + .position(|l| l.name == layer) + .unwrap_or_else(|| panic!("no layer named {layer}")); + let layer_ref = file.layer_ref(index).unwrap(); + + (0..file.frames().len()) + .filter_map(|f| file.resolve_cel(layer_ref, f)) + .filter_map(|cel| match &cel.kind { + CelKind::Raw { pixels, .. } | CelKind::Compressed { pixels, .. } => { + Some(Self::new( + pixels.width as i32, + pixels.height as i32, + pixels.data.clone(), + )) + } + _ => None, + }) + .collect() + } + + pub fn draw_at(&self, frame: &mut [u32], palette: &Palette, pos: IVec2) { + gfx::blit(frame, palette, &self.pixels, pos, self.size.x, 0); + } + + pub fn draw_tinted(&self, frame: &mut [u32], palette: &Palette, pos: IVec2, tint: u8) { + gfx::blit(frame, palette, &self.pixels, pos, self.size.x, tint); + } + + pub fn draw_rotated( + &self, + frame: &mut [u32], + palette: &Palette, + center: Vec2, + angle: f32, + tint: u8, + ) { + gfx::blit_rotated( + frame, + palette, + &self.pixels, + center, + self.size.x, + angle, + tint, + ); + } +} diff --git a/2026/games_submissions/asrael_io/source/src/starfield.rs b/2026/games_submissions/asrael_io/source/src/starfield.rs new file mode 100644 index 0000000..ab7a199 --- /dev/null +++ b/2026/games_submissions/asrael_io/source/src/starfield.rs @@ -0,0 +1,59 @@ +use crate::color::Palette; +use crate::color::db32::{BLUE, CYAN, GRAY, LIGHT_RED, LIME, PALE_BLUE, WHITE, YELLOW}; +use crate::rng::Rng; +use crate::{GAME_H, GAME_W}; + +const STAR_COLORS: [u8; 8] = [WHITE, GRAY, PALE_BLUE, CYAN, BLUE, YELLOW, LIGHT_RED, LIME]; + +struct Star { + color: u8, + speed: f32, + x: i32, + y: f32, +} + +#[derive(Default)] +pub struct Starfield { + stars: Vec<Star>, +} + +impl Starfield { + pub fn new(count: usize, rng: &mut Rng) -> Self { + let stars = (0..count) + .map(|i| { + let speed = match i % 3 { + 0 => 0.5, + 1 => 1.0, + _ => 1.5, + }; + + Star { + color: STAR_COLORS[rng.range(STAR_COLORS.len() as u32) as usize], + speed, + x: rng.range(GAME_W) as i32, + y: rng.range(GAME_H) as f32, + } + }) + .collect(); + + Self { stars } + } + + pub fn update(&mut self) { + for star in &mut self.stars { + star.y += star.speed; + if star.y >= GAME_H as f32 { + star.y -= GAME_H as f32; + } + } + } + + pub fn draw(&self, frame: &mut [u32], palette: &Palette, a: f32) { + for star in &self.stars { + let y = star.y - star.speed * (1.0 - a); + let y = (y as i32).rem_euclid(GAME_H as i32); + + frame[(y * GAME_W as i32 + star.x) as usize] = palette.at(star.color); + } + } +} |
