From ea5332e4a1a035e888709bee278fcafb182f7922 Mon Sep 17 00:00:00 2001 From: LLLL Colonq Date: Fri, 31 Jul 2026 05:00:49 -0400 Subject: Update --- 2026/games_submissions/rpc2dot0/src/assets.rs | 38 + .../rpc2dot0/src/assets/audio/crunch.wav | Bin 0 -> 123650 bytes .../rpc2dot0/src/assets/audio/explosion.wav | Bin 0 -> 13387 bytes .../rpc2dot0/src/assets/audio/fart.wav | Bin 0 -> 382542 bytes .../rpc2dot0/src/assets/audio/footsteps.wav | Bin 0 -> 140588 bytes .../rpc2dot0/src/assets/audio/test.wav | Bin 0 -> 924 bytes .../rpc2dot0/src/assets/meshes/square.obj | 15 + .../rpc2dot0/src/assets/shaders/flat/frag.glsl | 11 + .../rpc2dot0/src/assets/shaders/flat/vert.glsl | 4 + .../src/assets/textures/background_main.png | Bin 0 -> 572 bytes .../rpc2dot0/src/assets/textures/computer.png | Bin 0 -> 3616 bytes .../rpc2dot0/src/assets/textures/disquette.png | Bin 0 -> 445 bytes .../rpc2dot0/src/assets/textures/landing.png | Bin 0 -> 12693 bytes .../rpc2dot0/src/assets/textures/mrbleu.png | Bin 0 -> 1499 bytes .../src/assets/textures/mrbleu_lurking.png | Bin 0 -> 949 bytes .../rpc2dot0/src/assets/textures/mrgreen.png | Bin 0 -> 1492 bytes .../rpc2dot0/src/assets/textures/mrred.png | Bin 0 -> 1506 bytes .../rpc2dot0/src/assets/textures/mryellow.png | Bin 0 -> 2489 bytes .../rpc2dot0/src/assets/textures/mute.png | Bin 0 -> 544 bytes .../rpc2dot0/src/assets/textures/unmute.png | Bin 0 -> 545 bytes .../rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 | Bin 0 -> 1695306 bytes .../rpc2dot0/src/assets/video/adzzz/confused_c.mp4 | Bin 0 -> 1213146 bytes .../src/assets/video/adzzz/game_over_c.mp4 | Bin 0 -> 580466 bytes .../rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 | Bin 0 -> 1635459 bytes .../rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 | Bin 0 -> 737983 bytes .../src/assets/video/adzzz/seks_is_bad.mp4 | Bin 0 -> 1440141 bytes .../src/assets/video/adzzz/seks_is_gud.mp4 | Bin 0 -> 541533 bytes 2026/games_submissions/rpc2dot0/src/collision.rs | 99 +++ 2026/games_submissions/rpc2dot0/src/color.rs | 25 + 2026/games_submissions/rpc2dot0/src/computer.rs | 183 +++++ 2026/games_submissions/rpc2dot0/src/computer/os.rs | 117 +++ 2026/games_submissions/rpc2dot0/src/constants.rs | 109 +++ 2026/games_submissions/rpc2dot0/src/disquette.rs | 184 +++++ 2026/games_submissions/rpc2dot0/src/effects.rs | 58 ++ 2026/games_submissions/rpc2dot0/src/entity.rs | 90 +++ 2026/games_submissions/rpc2dot0/src/errors.rs | 7 + 2026/games_submissions/rpc2dot0/src/game.rs | 869 +++++++++++++++++++++ 2026/games_submissions/rpc2dot0/src/lib.rs | 40 + 2026/games_submissions/rpc2dot0/src/math.rs | 55 ++ 2026/games_submissions/rpc2dot0/src/message.rs | 26 + 2026/games_submissions/rpc2dot0/src/random.rs | 107 +++ 2026/games_submissions/rpc2dot0/src/state.rs | 484 ++++++++++++ 42 files changed, 2521 insertions(+) create mode 100644 2026/games_submissions/rpc2dot0/src/assets.rs create mode 100644 2026/games_submissions/rpc2dot0/src/assets/audio/crunch.wav create mode 100644 2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav create mode 100644 2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav create mode 100644 2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav create mode 100644 2026/games_submissions/rpc2dot0/src/assets/audio/test.wav create mode 100644 2026/games_submissions/rpc2dot0/src/assets/meshes/square.obj create mode 100644 2026/games_submissions/rpc2dot0/src/assets/shaders/flat/frag.glsl create mode 100644 2026/games_submissions/rpc2dot0/src/assets/shaders/flat/vert.glsl create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/background_main.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/computer.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/landing.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/mute.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/confused_c.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/game_over_c.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_bad.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_gud.mp4 create mode 100644 2026/games_submissions/rpc2dot0/src/collision.rs create mode 100644 2026/games_submissions/rpc2dot0/src/color.rs create mode 100644 2026/games_submissions/rpc2dot0/src/computer.rs create mode 100644 2026/games_submissions/rpc2dot0/src/computer/os.rs create mode 100644 2026/games_submissions/rpc2dot0/src/constants.rs create mode 100644 2026/games_submissions/rpc2dot0/src/disquette.rs create mode 100644 2026/games_submissions/rpc2dot0/src/effects.rs create mode 100644 2026/games_submissions/rpc2dot0/src/entity.rs create mode 100644 2026/games_submissions/rpc2dot0/src/errors.rs create mode 100644 2026/games_submissions/rpc2dot0/src/game.rs create mode 100644 2026/games_submissions/rpc2dot0/src/lib.rs create mode 100644 2026/games_submissions/rpc2dot0/src/math.rs create mode 100644 2026/games_submissions/rpc2dot0/src/message.rs create mode 100644 2026/games_submissions/rpc2dot0/src/random.rs create mode 100644 2026/games_submissions/rpc2dot0/src/state.rs (limited to '2026/games_submissions/rpc2dot0/src') diff --git a/2026/games_submissions/rpc2dot0/src/assets.rs b/2026/games_submissions/rpc2dot0/src/assets.rs new file mode 100644 index 0000000..fa160a1 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets.rs @@ -0,0 +1,38 @@ +teleia_macros::generate_assets!("src/assets/"); + +pub const AUDIO_ASSETS: &[(&str, &[u8])] = &[ + ("crunch", include_bytes!("assets/audio/crunch.wav")), + ("fart", include_bytes!("assets/audio/fart.wav")), + ("explosion", include_bytes!("assets/audio/explosion.wav")), +]; + +pub const VIDEO_ASSETS: &[(&str, &[u8])] = &[ + ( + "seks_is_bad.mp4", + include_bytes!("assets/video/adzzz/seks_is_bad.mp4"), + ), + ( + "seks_is_gud.mp4", + include_bytes!("assets/video/adzzz/seks_is_gud.mp4"), + ), + ( + "pinnesse_c.mp4", + include_bytes!("assets/video/adzzz/pinnesse_c.mp4"), + ), + ( + "hyped_c.mp4", + include_bytes!("assets/video/adzzz/hyped_c.mp4"), + ), + ( + "GG_win_c.mp4", + include_bytes!("assets/video/adzzz/GG_win_c.mp4"), + ), + ( + "game_over_c.mp4", + include_bytes!("assets/video/adzzz/game_over_c.mp4"), + ), + ( + "confused_c.mp4", + include_bytes!("assets/video/adzzz/confused_c.mp4"), + ), +]; diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/crunch.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/crunch.wav new file mode 100644 index 0000000..f11334f Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/audio/crunch.wav differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav new file mode 100644 index 0000000..c3e867f Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav new file mode 100644 index 0000000..ebb001d Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav new file mode 100644 index 0000000..57bfddf Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav new file mode 100644 index 0000000..0eabe85 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/meshes/square.obj b/2026/games_submissions/rpc2dot0/src/assets/meshes/square.obj new file mode 100644 index 0000000..7acdef6 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/meshes/square.obj @@ -0,0 +1,15 @@ +# Blender 3.6.2 +# www.blender.org +o Cube +v -1.000000 -1.000000 0.000000 +v 1.000000 -1.000000 0.000000 +v -1.000000 1.000000 0.000000 +v 1.000000 1.000000 0.000000 +vn -0.0000 -0.0000 -1.0000 +vt 0.0 1.0 +vt 1.0 0.0 +vt 0.0 0.0 +vt 1.0 1.0 +s 0 +f 3/1/1 2/2/1 1/3/1 +f 3/1/1 4/4/1 2/2/1 diff --git a/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/frag.glsl b/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/frag.glsl new file mode 100644 index 0000000..6ebdfa9 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/frag.glsl @@ -0,0 +1,11 @@ +uniform sampler2D texture_data; + +void main() +{ + vec2 tcfull = vec2(vertex_texcoord.x, 1.0 - vertex_texcoord.y); + vec4 texel = texture(texture_data, tcfull); + if (texel.a != 1.0) { + discard; + } + frag_color = texel; +} diff --git a/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/vert.glsl b/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/vert.glsl new file mode 100644 index 0000000..6e796cc --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/shaders/flat/vert.glsl @@ -0,0 +1,4 @@ +void main() +{ + default_main(); +} \ No newline at end of file diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/background_main.png b/2026/games_submissions/rpc2dot0/src/assets/textures/background_main.png new file mode 100644 index 0000000..82b3ebe Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/background_main.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png b/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png new file mode 100644 index 0000000..be5c723 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png b/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png new file mode 100644 index 0000000..12399f4 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png b/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png new file mode 100644 index 0000000..229e6ad Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png new file mode 100644 index 0000000..d16a717 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png new file mode 100644 index 0000000..3217129 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png new file mode 100644 index 0000000..2eb7210 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png new file mode 100644 index 0000000..2c4bc18 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png new file mode 100644 index 0000000..9784a8f Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png new file mode 100644 index 0000000..b4b81dd Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png b/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png new file mode 100644 index 0000000..278d227 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 new file mode 100644 index 0000000..c24a0d9 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/confused_c.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/confused_c.mp4 new file mode 100644 index 0000000..0c7756d Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/confused_c.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/game_over_c.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/game_over_c.mp4 new file mode 100644 index 0000000..bccb605 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/game_over_c.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 new file mode 100644 index 0000000..909c56a Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 new file mode 100644 index 0000000..38cdc53 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_bad.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_bad.mp4 new file mode 100644 index 0000000..fb9d490 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_bad.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_gud.mp4 b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_gud.mp4 new file mode 100644 index 0000000..7aac4d5 Binary files /dev/null and b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_gud.mp4 differ diff --git a/2026/games_submissions/rpc2dot0/src/collision.rs b/2026/games_submissions/rpc2dot0/src/collision.rs new file mode 100644 index 0000000..aaae9a7 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/collision.rs @@ -0,0 +1,99 @@ +use crate::color::EntityColor; +use crate::constants::{MAX_SPEED, MIN_SPEED, SPEED_COLLIDE_MULT}; +use crate::entity::Entity; +use crate::math::BoundingCircle; +use crate::random::RandomState; + +pub fn detect_collisions(entities: &[Entity], buffer: &mut Vec<(usize, usize)>) { + buffer.clear(); + let len = entities.len(); + + for i in 0..len { + if !entities[i].alive { + continue; + } + for j in (i + 1)..len { + if !entities[j].alive { + continue; + } + if entities[i].overlaps_with(&entities[j]) { + buffer.push((i, j)); + } + } + } +} + +pub fn process_collisions( + entities: &mut Vec, + rng: &mut RandomState, + _next_entity_id: &mut u32, + collision_buffer: &mut Vec<(usize, usize)>, + elapsed_ms: f32, + _events: &mut Vec, +) { + detect_collisions(entities, collision_buffer); + + let new_entities = Vec::new(); + + for &(i, j) in collision_buffer.iter() { + if i >= entities.len() || j >= entities.len() { + continue; + } + if !entities[i].alive || !entities[j].alive { + continue; + } + + let color_a = entities[i].color; + let color_b = entities[j].color; + + match (color_a, color_b) { + (EntityColor::Green, EntityColor::Green) => { + if (rng.next_u32() & 1) == 0 { + entities[i].color = EntityColor::Yellow; + entities[j].color = EntityColor::Red; + entities[j].spawn_time_ms = Some(elapsed_ms); + } else { + entities[i].color = EntityColor::Red; + entities[i].spawn_time_ms = Some(elapsed_ms); + entities[j].color = EntityColor::Yellow; + } + } + (EntityColor::Red, EntityColor::Red) => { + let a = &entities[i]; + let b = &entities[j]; + let collision_point = (a.pos + b.pos) * 0.5; + + entities[i].alive = false; + entities[j].alive = false; + + let best_green_idx = entities + .iter() + .enumerate() + .filter(|(_, e)| e.alive && e.color == EntityColor::Green) + .min_by(|(_, e1), (_, e2)| { + e1.pos + .distance_squared(collision_point) + .partial_cmp(&e2.pos.distance_squared(collision_point)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(idx, _)| idx); + + if let Some(idx) = best_green_idx { + entities[idx].alive = false; + } + } + _ => { + let a_speed = (entities[i].speed * SPEED_COLLIDE_MULT).clamp(MIN_SPEED, MAX_SPEED); + let b_speed = (entities[j].speed * SPEED_COLLIDE_MULT).clamp(MIN_SPEED, MAX_SPEED); + entities[i].speed = a_speed; + entities[j].speed = b_speed; + entities[i].normalize_velocity(); + entities[j].normalize_velocity(); + } + } + } + + if !new_entities.is_empty() { + entities.extend(new_entities); + } +} diff --git a/2026/games_submissions/rpc2dot0/src/color.rs b/2026/games_submissions/rpc2dot0/src/color.rs new file mode 100644 index 0000000..ed81ec4 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/color.rs @@ -0,0 +1,25 @@ +use crate::assets; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] + +pub enum EntityColor { + Red = 0, + Green = 1, + Bleu = 2, + Yellow = 3, +} + +pub trait ColorExt { + fn texture(&self) -> assets::Texture; +} + +impl ColorExt for EntityColor { + fn texture(&self) -> assets::Texture { + match self { + EntityColor::Red => assets::Texture::Mrred, + EntityColor::Green => assets::Texture::Mrgreen, + EntityColor::Bleu => assets::Texture::Mrbleu, + EntityColor::Yellow => assets::Texture::Mryellow, + } + } +} diff --git a/2026/games_submissions/rpc2dot0/src/computer.rs b/2026/games_submissions/rpc2dot0/src/computer.rs new file mode 100644 index 0000000..e25fb65 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/computer.rs @@ -0,0 +1,183 @@ +use crate::constants::*; +use crate::disquette::Disquette; +use crate::game::Game; +use crate::math::ContainsPoint; +use crate::math::Rectangle; +use crate::random::RandomState; + +pub mod os; + +use os::Os; +use teleia::context; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComputerPhase { + Off, + Booting, + On, + ShutDown, +} + +impl From for &'static str { + fn from(value: ComputerPhase) -> Self { + match value { + ComputerPhase::Off => "Off", + ComputerPhase::Booting => "Booting", + ComputerPhase::On => "On", + ComputerPhase::ShutDown => "ShutDown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PowerButtonAction { + On, + Off, +} + +impl From for &'static str { + fn from(value: PowerButtonAction) -> Self { + match value { + PowerButtonAction::On => "On", + PowerButtonAction::Off => "Off", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Computer { + pub coord: glam::Vec2, + pub phase: ComputerPhase, + pub disquette: Disquette, + pub os: Os, +} + +impl Computer { + pub fn new( + logical_width: f32, + logical_height: f32, + rng: &mut RandomState, + difficulty: f32, + ) -> Self { + const HALF_W: f32 = COMPUTER_WIDTH / 2.0; + const HALF_H: f32 = COMPUTER_HEIGHT / 2.0; + let x = rng.next_range(HALF_W, logical_width - HALF_W); + let y = rng.next_range(HALF_H, logical_height - HALF_H); + log::info!("eatyourgreens:: Computer new at ({}, {}) ", x, y); + let mut temp = Self { + coord: glam::Vec2::new(x, y), + phase: ComputerPhase::Off, + disquette: Disquette { + coord: glam::Vec2::ZERO, + vel: glam::Vec2::ZERO, + size: 0.0, + phase: crate::disquette::DisquettePhase::Free, + }, + os: Os::from(ComputerPhase::Off), + }; + + let hitzone = temp.hit_zone(difficulty); + temp.disquette = Disquette::new(logical_width, logical_height, rng, Some(hitzone)); + temp + } + + pub fn disquette_inserted(&self) -> bool { + self.disquette.is_inserted() + } + + pub fn boot(&mut self) { + log::info!("eatyourgreens:: computer boot"); + self.phase = ComputerPhase::Booting; + self.os = Os::from(self.phase); + } + + pub fn on(&mut self, ctx: &context::Context) { + log::info!("eatyourgreens:: computer on"); + self.phase = ComputerPhase::On; + self.os.set(self.phase); + self.os.os_play(&ctx); + } + + pub fn shutdown(&mut self) { + log::info!("eatyourgreens:: computer shutdown"); + self.phase = ComputerPhase::Off; + self.os.set(self.phase); + } + + pub fn already_used(&self) -> bool { + self.phase != ComputerPhase::Off + } + + // + // hitzone calculation + // + // computer pos is (x, y)so its center. + // first find the top-left corner of the computer bbox. + // second define "hitzone" as the inner rectangle offset from that top-left corner. + + // us diffculity to shrink the area, so user msut aim better at higher difficulties + + // topleft(x -width/2 , y-height/2) + // +------------------------------------------+ --- + // | | | + // | TRIGGER_OFFSET_Y | | + // | v | | COMPUTER_HEIGHT + // | -> +----------------------------+ --- | | + // | ^ | diff_y | | | | + // | | | -> +---------------+ --- | | | | + // | | | ^ | | | | | TRIGGER_HEIGHT + // | | | | | Actual Hit |actual_| | | | + // | | | | | Zone (shrunk) |height | | | | + // | | | | +---------------+ --- | | | | + // | | | diff_x | | | | + // | | +----------------------------+ --- | | + // | | | | + // | TRIGGER_OFFSET_X | | + // +------------------------------------------+ --- + // |--------------COMPUTER_WIDTH--------------| + + pub fn hit_zone(&self, difficulty: f32) -> Rectangle { + let topleft = self.coord - glam::Vec2::new(COMPUTER_WIDTH / 2.0, COMPUTER_HEIGHT / 2.0); + + let size_multiplier = 1.0 / difficulty.max(1.0); + let actual_size = glam::Vec2::new(TRIGGER_WIDTH, TRIGGER_HEIGHT) * size_multiplier; + + let diff = (glam::Vec2::new(TRIGGER_WIDTH, TRIGGER_HEIGHT) - actual_size) / 2.0; + + let trigger_pos = topleft + glam::Vec2::new(TRIGGER_OFFSET_X, TRIGGER_OFFSET_Y) + diff; + + Rectangle::from_min_max(trigger_pos, trigger_pos + actual_size) + } + + pub fn check_power_button( + &self, + point: glam::Vec2, + difficulty: f32, + ) -> Option { + let topleft = self.coord - glam::Vec2::new(COMPUTER_WIDTH / 2.0, COMPUTER_HEIGHT / 2.0); + let pb_pos = topleft + glam::Vec2::new(POWER_BUTTON_OFFSET_X, POWER_BUTTON_OFFSET_Y); + let pb_size = glam::Vec2::new(POWER_BUTTON_WIDTH, POWER_BUTTON_HEIGHT); + + let pb_rect = Rectangle::from_min_max(pb_pos, pb_pos + pb_size); + + if pb_rect.contains(point) { + if difficulty > 1.0 { + let half_width = POWER_BUTTON_WIDTH / 2.0; + if point.x < pb_pos.x + half_width { + Some(PowerButtonAction::Off) + } else { + Some(PowerButtonAction::On) + } + } else { + Some(PowerButtonAction::On) + } + } else { + None + } + } + + pub fn contains_disquette(&self, disquette: &Disquette, difficulty: f32) -> bool { + let hitzone = self.hit_zone(difficulty); + hitzone.contains(disquette.coord) + } +} diff --git a/2026/games_submissions/rpc2dot0/src/computer/os.rs b/2026/games_submissions/rpc2dot0/src/computer/os.rs new file mode 100644 index 0000000..797e07e --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/computer/os.rs @@ -0,0 +1,117 @@ +use std::fmt::Debug; + +use crate::computer::ComputerPhase; +use crate::message::Message; +use crate::{assets::VIDEO_ASSETS, random::RandomState}; +use crate::{constants::*, game}; +use teleia::context; +use web_sys::{Blob, BlobPropertyBag, Url}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Os { + state: OsState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OsState { + Booting, + On, + ShutDown, + Off, +} + +// TODO ??? +impl From for ComputerPhase { + fn from(value: OsState) -> Self { + match value { + OsState::Booting => ComputerPhase::Booting, + OsState::On => ComputerPhase::On, + OsState::ShutDown => ComputerPhase::ShutDown, + OsState::Off => ComputerPhase::Off, + } + } +} + +impl From for OsState { + fn from(value: ComputerPhase) -> Self { + match value { + ComputerPhase::Off => OsState::Off, + ComputerPhase::Booting => OsState::Booting, + ComputerPhase::On => OsState::On, + ComputerPhase::ShutDown => OsState::ShutDown, + } + } +} + +impl Os { + pub fn new() -> Self { + Self { + state: OsState::Off, + } + } + pub fn from(phase: ComputerPhase) -> Self { + Self { + state: OsState::from(phase), + } + } + + pub fn set(&mut self, phase: ComputerPhase) { + self.state = OsState::from(phase); + } +} + +impl Default for Os { + fn default() -> Self { + Self { + state: OsState::Off, + } + } +} + +impl Os { + pub fn boot(rng: &mut RandomState) { + let idx = rng.next_range(0.0, VIDEO_ASSETS.len() as f32) as usize; + let bytes = VIDEO_ASSETS[idx].1; + + // huh ? `of1` nice api name + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob_props = BlobPropertyBag::new(); + blob_props.set_type("video/mp4"); + + let blob = match Blob::new_with_u8_array_sequence_and_options(&parts, &blob_props) { + Ok(b) => b, + Err(_) => { + log::error!("eatyourgreens:: failed to create blob"); + return; + } + }; + + let url = match Url::create_object_url_with_blob(&blob) { + Ok(s) => s, + Err(_) => { + log::error!("eatyourgreens:: failed to create object URL"); + return; + } + }; + if let Some(window) = web_sys::window() { + let msg = Message { + op: "play_video", + verb: None, + win: None, + score: None, + video_url: Some(url), + }; + Message::post(&window, &msg); + } else { + log::error!("eatyourgreens:: failed to get window and send os msg"); + } + } + + pub fn os_play(&self, ctx: &context::Context) { + log::debug!("eatyourgreens:: os play game:"); + } + + pub fn os_quit() { + log::debug!("eatyourgreens:: os quit"); + } +} diff --git a/2026/games_submissions/rpc2dot0/src/constants.rs b/2026/games_submissions/rpc2dot0/src/constants.rs new file mode 100644 index 0000000..0e16b2b --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/constants.rs @@ -0,0 +1,109 @@ +use crate::color::EntityColor; +use wasm_bindgen::prelude::wasm_bindgen; + +pub const HEIGHT: u32 = 160; +pub const WIDTH: u32 = 240; +pub const LANDING_TEXTURE_HEIGHT: i32 = 160; +pub const LANDING_TEXTURE_WIDTH: i32 = 240; +pub const MUTE_ICON_SIZE: f32 = 16.0; +pub const MUTE_ICON_MARGIN: f32 = 0.0; + +pub const GAME_DURATION_MS: u32 = 15_000; +pub const NB_GREENS: i32 = 15; +pub const INITIAL_SPAWN: u32 = 8; +pub const MAX_ENTITIES: u32 = 40; +pub const BASE_SPEED: f32 = 24.0; +pub const SPEED_COLLIDE_MULT: f32 = 2.0; +pub const ENTITY_RADIUS: f32 = 4.8; +pub const INITIAL_SPAWN_SPREAD_MS: f32 = 1_000.0; +pub const SPAWN_RATE_MIN_MS: u32 = 500; +pub const SPAWN_RATE_MAX_MS: u32 = 1_200; +pub const DAMPING_FACTOR: f32 = 0.98; +pub const JITTER_STRENGTH: f32 = 0.12; +pub const MIN_SPEED: f32 = 2.6; +pub const MAX_SPEED: f32 = 64.0; +pub const SPAWN_PADDING: f32 = 1.0; +pub const ENTITY_SCALE_FACTOR: f32 = 1.5; +pub const RED_COUNTDOWN_MS: f32 = 3_000.0; +pub const ROGUE_DURATION_MS: f32 = 3_000.0; +pub const PACMAN_CHASE_PROB: f64 = 0.30; +pub const PACMAN_CHASE_SPEED_MULT: f32 = 1.5; + +pub const BASELINE_RATIOS: [f32; 4] = { + let mut b = [0.0f32; 4]; + b[EntityColor::Red as usize] = 0.10; + b[EntityColor::Green as usize] = 0.60; + b[EntityColor::Bleu as usize] = 0.10; + b[EntityColor::Yellow as usize] = 0.20; + b +}; + +pub const fn get_baseline_ratio(color: EntityColor, difficulty: f32) -> f32 { + const SHIFT_PER_LEVEL: f32 = 0.15; + const GREEN_FLOOR: f32 = 0.05; + + let green_base = BASELINE_RATIOS[EntityColor::Green as usize]; + let red_base = BASELINE_RATIOS[EntityColor::Red as usize]; + + // how much we can actually shift without breaching the green floor. + let max_shift = green_base - GREEN_FLOOR; + let shift = (SHIFT_PER_LEVEL * (difficulty - 1.0).max(0.0)).min(max_shift); + + match color { + EntityColor::Green => green_base - shift, + EntityColor::Red => red_base + shift, + // Bleu and Yellow stay fixed; only the Green/Red tension is modulated. + c => BASELINE_RATIOS[c as usize], + } +} + +// UI constants +pub const TITLE_HEADING_TEXT_COLOR: glam::Vec3 = glam::Vec3::new(0.105, 0.262, 0.196); +pub const TITLE_DESCRIPTION_TEXT_COLOR: glam::Vec3 = glam::Vec3::new(0.184, 0.321, 0.2); +pub const START_BUTTON_TEXT_COLOR: glam::Vec3 = glam::Vec3::new(1.0, 1.0, 1.0); +pub const TITLE_START_BUTTON_LABEL: &str = "START"; +pub fn title_score_goal() -> String { + format!("Score {} or more to win", NB_GREENS) +} +pub const TITLE_CLICK_START_HINT: &str = "Click START to begin the 15s* round."; +//pub const TITLE_ROUND_WARNING: &str = "(*in this computer seconds might be longer, TBI)"; +pub const TITLE_SCORING_HINT: &str = "Green gives +1, all other colors give -1."; +pub const TITLE_HINT: &str = "hint: booting the computer is optional."; + +pub const HUD_TEXT_COLOR: glam::Vec3 = glam::Vec3::new(0.2, 0.2, 0.2); +pub const HUD_SCORE_LABEL_PREFIX: &str = "Score: "; +pub const HUD_TIME_LABEL_PREFIX: &str = "Time: "; +pub const HUD_TIME_LABEL_SUFFIX: &str = "s"; +pub const HUD_FPS_LABEL_PREFIX: &str = "FPS: "; +pub const HUD_SCORE_POSITION: glam::Vec2 = glam::Vec2::new(2.6, 6.6); +pub const HUD_FPS_POSITION: glam::Vec2 = glam::Vec2::new(2.6, 20.0); +pub const RESULT_DISPLAY_DELAY_MS: f64 = 2000.0; +#[wasm_bindgen] +pub fn get_result_display_delay_ms() -> f64 { + RESULT_DISPLAY_DELAY_MS +} + +// UI rendering colors and uniforms +pub const RED_COUNTDOWN_BAR_COLOR: glam::Vec4 = glam::Vec4::new(1.0, 0.266, 0.266, 1.0); +pub const BUTTON_FILL_COLOR: glam::Vec4 = glam::Vec4::new(0.0, 0.0, 1.0, 0.9); +pub const GAME_OVER_OVERLAY_COLOR: glam::Vec4 = glam::Vec4::new(1.0, 1.0, 1.0, 0.82); +pub const UI_UNIFORM_COLOR_NAME: &str = "color"; +pub const UI_UNIFORM_OPACITY_NAME: &str = "opacity"; +pub const FLASH_SCREEN_BASE_COLOR: glam::Vec3 = glam::Vec3::new(1.0, 0.0, 0.0); +pub const GAME_OVER_TEXT_COLOR: glam::Vec3 = glam::Vec3::new(0.105, 0.262, 0.196); +pub const GAME_OVER_TITLE_TEXT: &str = "Game Over"; +pub const GAME_OVER_RESULT_PREFIX: &str = "Result: "; + +// computer +pub const COMPUTER_WIDTH: f32 = 42.6; +pub const COMPUTER_HEIGHT: f32 = 42.6; +pub const TRIGGER_OFFSET_X: f32 = 9.8; +pub const TRIGGER_OFFSET_Y: f32 = 4.0; +pub const TRIGGER_WIDTH: f32 = 8.0; +pub const TRIGGER_HEIGHT: f32 = 6.1; +pub const POWER_BUTTON_OFFSET_X: f32 = 28.8; +pub const POWER_BUTTON_OFFSET_Y: f32 = 2.6; +pub const POWER_BUTTON_WIDTH: f32 = 9.6; +pub const POWER_BUTTON_HEIGHT: f32 = 5.8; + +// Subgame diff --git a/2026/games_submissions/rpc2dot0/src/disquette.rs b/2026/games_submissions/rpc2dot0/src/disquette.rs new file mode 100644 index 0000000..7940d17 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/disquette.rs @@ -0,0 +1,184 @@ +use crate::math::{ContainsPoint, Rectangle}; +use crate::random::RandomState; +use teleia::state; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisquettePhase { + Free, + Inserted, +} + +impl From for &'static str { + fn from(value: DisquettePhase) -> Self { + match value { + DisquettePhase::Free => "Free", + DisquettePhase::Inserted => "Inserted", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Disquette { + pub coord: glam::Vec2, + pub vel: glam::Vec2, + pub size: f32, + pub phase: DisquettePhase, +} + +impl Disquette { + pub fn new( + logical_width: f32, + logical_height: f32, + rng: &mut RandomState, + computer_hitzone: Option, + ) -> Self { + const SIZE: f32 = 6.4; + const HALF: f32 = SIZE / 2.0; + log::info!( + "eatyourgreens:: Disquette new at ({}, {})", + logical_width, + logical_height + ); + + let x = rng.next_range(HALF, logical_width - HALF); + let y = rng.next_range(HALF, logical_height - HALF); + let mut coord = glam::Vec2::new(x, y); + + // prevent spawning inside the computer hitzone + // this is a bit stupidos, but it works for now. + // @TODO: find a better way to do this. like using rng api (random.rs) where you can + // specify the excluded area, or something less retarded. + if let Some(hitzone) = computer_hitzone { + // wasting few cpu cycles doesn't hurt anybody + while hitzone.contains(coord) { + coord.x = rng.next_range(HALF, logical_width - HALF); + coord.y = rng.next_range(HALF, logical_height - HALF); + } + } + + Self { + coord, + vel: glam::Vec2::new( + // -/+ 21.3 as value cause it works, yeah idc + if rng.next_bool(0.5) { 21.3 } else { -21.3 }, + if rng.next_bool(0.5) { 21.3 } else { -21.3 }, + ), + size: SIZE, + phase: DisquettePhase::Free, + } + } + + pub fn is_inserted(&self) -> bool { + self.phase == DisquettePhase::Inserted + } + + pub fn update( + &mut self, + width: f32, + height: f32, + keys: &state::Keys, + delta_ms: f32, + difficulty: f32, + computer_hitzone: Option, + ) { + let dt = delta_ms / 1000.0; + let impulse = 106.7 * difficulty; + + let driven_by_user = keys.left() || keys.right() || keys.up() || keys.down(); + + if keys.left() { + self.vel.x -= impulse * dt; + } + if keys.right() { + self.vel.x += impulse * dt; + } + if keys.up() { + self.vel.y -= impulse * dt; + } + if keys.down() { + self.vel.y += impulse * dt; + } + + // speed limiting + // @TODO : hum value below was adjusted manualy it might + // be wrong with game rendering speed? ?? + let max_speed = 53.3 * difficulty; + let speed = self.vel.length(); + if speed > max_speed { + self.vel = self.vel.normalize() * max_speed; + } + + self.coord += self.vel * dt; + + let half = self.size / 2.0; + let min_bounds = glam::Vec2::splat(half); + let max_bounds = glam::Vec2::new(width - half, height - half); + + // boundary collision and bouncing + // left edge : clamp position and ensure it moves right + if self.coord.x < min_bounds.x { + self.coord.x = min_bounds.x; + // .abs() guarantees velocity is positive (moving right) + self.vel.x = self.vel.x.abs(); + } + // right edge : clamp position and ensure it moves left + else if self.coord.x > max_bounds.x { + self.coord.x = max_bounds.x; + // -.abs() guarantees velocity is negative (moving left) + self.vel.x = -self.vel.x.abs(); + } + + // top and bottom edges + if self.coord.y < min_bounds.y { + self.coord.y = min_bounds.y; + // .abs() guarantees velocity is positive (moving down) + self.vel.y = self.vel.y.abs(); + } else if self.coord.y > max_bounds.y { + self.coord.y = max_bounds.y; + // -.abs() guarantees velocity is negative (moving up) + self.vel.y = -self.vel.y.abs(); + } + + // bounce off computer hitzone unless user is actively driving it in + if !driven_by_user && let Some(hitzone) = computer_hitzone { + let prev_pos = self.coord - self.vel * dt; + + if hitzone.contains(self.coord) { + let was_outside = !hitzone.contains(prev_pos); + if was_outside { + let was_left = prev_pos.x < hitzone.min.x; + let was_right = prev_pos.x > hitzone.max.x; + let was_top = prev_pos.y < hitzone.min.y; + let was_bottom = prev_pos.y > hitzone.max.y; + + if was_left || was_right { + // reverse horizontal velocity to bounce off + self.vel.x = -self.vel.x; + // +/- 0.1 is a tiny extra push away from the hitzone. + // this prevent the disquette from getting stuck exactly on the boundary + // and repeatedly triggers collisions + // this bug took 2h from my life, fuck you disquette, you floppy disk :/. + self.coord.x = if was_left { + hitzone.min.x - 0.1 + } else { + hitzone.max.x + 0.1 + }; + } + if was_top || was_bottom { + // reverse vertical velocity to bounce off + self.vel.y = -self.vel.y; + // +/- 0.1 adds a tiny gap to safely escape the hitzone + self.coord.y = if was_top { + hitzone.min.y - 0.1 + } else { + hitzone.max.y + 0.1 + }; + } + } + } + } + } +} + +// the hours spent on this zone/bouncing detection bs, I should've done something more productive, but +// whatever. diff --git a/2026/games_submissions/rpc2dot0/src/effects.rs b/2026/games_submissions/rpc2dot0/src/effects.rs new file mode 100644 index 0000000..02b33ef --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/effects.rs @@ -0,0 +1,58 @@ +use crate::color::EntityColor; +use crate::constants::*; + +#[derive(Debug, Clone)] +pub struct PacmanChase { + pub speed: f32, +} + +impl PacmanChase { + pub fn new(speed: f32) -> Self { + Self { speed } + } +} + +impl Default for PacmanChase { + fn default() -> Self { + Self::new(BASE_SPEED) + } +} +#[derive(Debug, Clone)] +pub struct Rogue { + pub until_ms: f32, + pub hover_color: EntityColor, +} + +impl Rogue { + pub fn new(until_ms: f32, hover_color: EntityColor) -> Self { + Self { + until_ms, + hover_color, + } + } +} + +impl Default for Rogue { + fn default() -> Self { + Self::new(ROGUE_DURATION_MS, EntityColor::Yellow) + } +} + +pub struct Explosion { + pub x: f32, + pub y: f32, + pub age_ms: f32, + pub duration_ms: f32, + pub radius: f32, +} +impl Explosion { + pub fn new(x: f32, y: f32) -> Self { + Self { + x, + y, + age_ms: 0.0, + duration_ms: 400.0, + radius: 8.0, + } + } +} diff --git a/2026/games_submissions/rpc2dot0/src/entity.rs b/2026/games_submissions/rpc2dot0/src/entity.rs new file mode 100644 index 0000000..3b4200f --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/entity.rs @@ -0,0 +1,90 @@ +use crate::color::EntityColor; +use crate::constants::{DAMPING_FACTOR, MIN_SPEED}; +use crate::effects::{PacmanChase, Rogue}; +use crate::math::{BoundingCircle, Vec2}; + +pub trait Update { + fn update(&mut self, delta_ms: f32); +} + +#[derive(Debug, Clone)] +pub struct Entity { + pub _id: u32, + pub pos: Vec2, + pub vel: Vec2, + pub speed: f32, + pub color: EntityColor, + pub radius: f32, + pub alive: bool, + pub spawn_time_ms: Option, + pub rogue: Option, + pub pacman_chase: Option, +} + +impl Entity { + pub fn new(id: u32, pos: Vec2, vel: Vec2, color: EntityColor, speed: f32, radius: f32) -> Self { + let mut entity = Self { + _id: id, + pos, + vel, + speed, + color, + radius, + alive: true, + spawn_time_ms: None, + rogue: None, + pacman_chase: None, + }; + entity.normalize_velocity(); + entity + } + + pub fn normalize_velocity(&mut self) { + let length = self.vel.length(); + if length <= f32::EPSILON { + self.vel = Vec2::new(1.0, 0.0); + } else { + self.vel = self.vel / length * self.speed; + } + } + + pub fn apply_jitter(&mut self, jitter: Vec2) { + self.vel += jitter; + self.normalize_velocity(); + } + + pub fn dampen_speed(&mut self, max_speed: f32) { + self.speed = (self.speed * DAMPING_FACTOR).clamp(MIN_SPEED, max_speed); + self.normalize_velocity(); + } + + pub fn bounds_bounce(&mut self, width: f32, height: f32) { + let min_bounds = Vec2::splat(self.radius); + let max_bounds = Vec2::new(width, height) - min_bounds; + + let hit_min = self.pos.cmple(min_bounds); + let hit_max = self.pos.cmpge(max_bounds); + + self.pos = self.pos.clamp(min_bounds, max_bounds); + + self.vel = Vec2::select(hit_min, self.vel.abs(), self.vel); + self.vel = Vec2::select(hit_max, -self.vel.abs(), self.vel); + } +} + +impl Update for Entity { + fn update(&mut self, delta_ms: f32) { + let dt = delta_ms / 1000.0; + self.pos += self.vel * dt; + } +} + +impl BoundingCircle for Entity { + fn center(&self) -> Vec2 { + self.pos + } + + fn radius(&self) -> f32 { + self.radius + } +} diff --git a/2026/games_submissions/rpc2dot0/src/errors.rs b/2026/games_submissions/rpc2dot0/src/errors.rs new file mode 100644 index 0000000..71a67cf --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/errors.rs @@ -0,0 +1,7 @@ +use crate::color::EntityColor; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ClickOutcome { + Hit(EntityColor), + Miss, +} diff --git a/2026/games_submissions/rpc2dot0/src/game.rs b/2026/games_submissions/rpc2dot0/src/game.rs new file mode 100644 index 0000000..7650e72 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/game.rs @@ -0,0 +1,869 @@ +use std::collections::HashMap; + +use teleia::renderer::UberFlags; +use teleia::*; + +use crate::assets; +use crate::assets::AUDIO_ASSETS; +use crate::color::{ColorExt, EntityColor}; +use crate::computer::os::Os; +use crate::computer::*; +use crate::constants::*; +use crate::disquette::*; +use crate::effects::*; +use crate::errors::*; +use crate::math::*; +use crate::message::Message; +use crate::state::{GamePhase, GameState}; + +pub struct Game { + renderer: renderer::Renderer, + state: GameState, + mouse: glam::Vec2, + explosions: Vec, + greens_wiped_flash: f32, + computer: Computer, + start_button_rect: Option, + is_muted: bool, + mute_button_rect: Option, + is_embedded: bool, + last_time_ms: Option, + finish_time_ms: Option, + done_sent: bool, +} + +impl Game { + pub fn new(ctx: &context::Context, is_embedded: bool) -> Self { + log::info!("eatyourgreens:: entering new Game"); + let mut state = GameState::new(); + let computer = Computer::new( + ctx.render_width, + ctx.render_height, + &mut state.rng, + state.difficulty, + ); + Self { + renderer: renderer::Renderer::new(ctx, assets::Assets::new), + state, + mouse: glam::Vec2::ZERO, + explosions: Vec::new(), + greens_wiped_flash: 0.0, + computer, + start_button_rect: None, + is_muted: false, + mute_button_rect: None, + is_embedded, + last_time_ms: None, + finish_time_ms: None, + done_sent: false, + } + } + + pub fn set_difficulty(&mut self, difficulty: f32) { + log::info!("eatyourgreens:: set_difficulty: {}", difficulty); + self.state.set_difficulty(difficulty); + } + + pub fn receive_op(&mut self, ctx: &context::Context, st: &mut state::State, op: String) { + log::info!( + "eatyourgreens:: receive op: {} (phase: {:?})", + op, + self.state.phase() + ); + match self.state.phase() { + GamePhase::Waiting | GamePhase::Finished => { + if self.is_embedded && op == "start" { + self.reset_round(ctx, st); + const VERB: &str = "eat your greens"; + self.send_op("started", Some(VERB), None, None, None); + } else { + log::warn!("eatyourgreens:: received unexpected op: {} (waiting) ", op); + } + } + GamePhase::Computer(computer_phase) => { + let currentop = op.as_str(); + match computer_phase { + ComputerPhase::Booting => { + if currentop == "video_finished" { + self.state.set_phase(GamePhase::Computer(ComputerPhase::On)); + self.computer.on(ctx); + } else { + log::warn!( + "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", + op, + computer_phase + ); + } + } + ComputerPhase::On => { + if currentop == "computing" { + // nothing to do in here + // we wait for os-micro-game to send shutdown + log::info!("eatyourgreens:: computer computing. do nothing."); + } else if currentop == "shutdown" { + log::info!( + "eatyourgreens:: computer shutdown. Transition to main game." + ); + self.computer.shutdown(); + self.state.set_phase(GamePhase::Playing); + } else { + log::warn!( + "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", + op, + computer_phase + ); + } + } + + _ => { + // we shouldn't be receiving events while computer is off or shutdown + log::warn!( + "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", + op, + computer_phase + ); + } + } + } + _ => { + log::warn!( + "eatyourgreens:: received unexpected op: {} for phase: {:?}", + op, + self.state.phase() + ); + } + } + } + + fn reset_round(&mut self, ctx: &context::Context, st: &mut state::State) { + log::info!("eatyourgreens:: reset_round"); + self.state.start_round(); + + let computer = Computer::new( + ctx.render_width, + ctx.render_height, + &mut self.state.rng, + self.state.difficulty, + ); + + self.computer = computer; + self.explosions.clear(); + self.greens_wiped_flash = 0.0; + if self.is_muted { + st.audio.stop_music(); + } + self.last_time_ms = None; + self.finish_time_ms = None; + self.done_sent = false; + } + + fn push_explosion(&mut self, x: f32, y: f32) { + self.explosions.push(Explosion::new(x, y)); + } + + fn update_explosions(&mut self, delta_ms: f32) { + for exp in &mut self.explosions { + exp.age_ms += delta_ms; + } + self.explosions.retain(|e| e.age_ms < e.duration_ms); + } + + fn handle_state_event(&mut self, event: String, st: &mut state::State) { + log::info!("eatyourgreens:: handle_state_event: {}", event); + if let Some(coords) = event.strip_prefix("explode:") { + if !self.is_muted { + st.audio.play_sfx("explosion"); + } + let (x, y): (f32, f32) = parse_coords(coords); + self.push_explosion(x, y); + self.greens_wiped_flash = 300.0; + return; + } + + if let Some(coords) = event.strip_prefix("click_red:") { + if !self.is_muted { + st.audio.play_sfx("explosion"); + } + let (x, y) = parse_coords(coords); + self.push_explosion(x, y); + return; + } + } + + fn render_title(&mut self, ctx: &context::Context, st: &mut state::State) { + self.renderer + .bind_uber_2d(ctx, st, UberFlags::TEXTURE_COLOR | UberFlags::SPRITE); + self.renderer + .bind_texture(ctx, st, assets::Texture::Landing); + self.renderer.set_texture_offset( + ctx, + st, + 1, + 1, + LANDING_TEXTURE_WIDTH, + LANDING_TEXTURE_HEIGHT, + ); + self.renderer + .set_position_2d(ctx, st, glam::Vec2::ZERO, st.render_dims); + self.renderer.set_f32(ctx, st, UI_UNIFORM_OPACITY_NAME, 0.5); + self.renderer.render_square(ctx, st); + + let center_x: f32 = ctx.render_width / 2.0; + let mut y: f32 = ctx.render_height * 0.3; + let line_height: f32 = self.renderer.font_char_height(st) + 1.0; + + self.draw_text_centered( + ctx, + st, + center_x, + y, + TITLE_HEADING_TEXT_COLOR, + &title_score_goal(), + ); + + y += line_height; + self.draw_text_centered( + ctx, + st, + center_x, + y, + TITLE_HEADING_TEXT_COLOR, + TITLE_CLICK_START_HINT, + ); + + y += line_height * 2.0; + if !self.is_embedded { + self.draw_button_centered( + ctx, + st, + center_x, + y, + START_BUTTON_TEXT_COLOR, + TITLE_START_BUTTON_LABEL, + ); + } else { + self.start_button_rect = None; + } + + y += line_height * 3.0; + self.draw_text_centered( + ctx, + st, + center_x, + y, + TITLE_DESCRIPTION_TEXT_COLOR, + TITLE_SCORING_HINT, + ); + + y += line_height; + self.draw_text_centered( + ctx, + st, + center_x, + y, + TITLE_DESCRIPTION_TEXT_COLOR, + TITLE_HINT, + ); + + self.draw_mute_icon(ctx, st); + } + + fn render_background(&mut self, ctx: &context::Context, st: &mut state::State) { + ctx.clear_color(glam::Vec4::ZERO); + ctx.clear(); + self.renderer.texture_screen( + ctx, + st, + assets::Texture::BackgroundMain, + glam::Vec2::ZERO, + glam::Vec2::new(ctx.render_width, ctx.render_height), + ); + } + + fn render_entities(&mut self, ctx: &context::Context, st: &mut state::State) { + let mapped_mouse = self.map_mouse_to_render(ctx, st); + let elapsed_ms = self.state.elapsed_ms(); + for entity in self.state.entities() { + if !entity.alive { + continue; + } + + let size: f32 = entity.radius * 2.0; + let pos: glam::Vec2 = glam::Vec2::new(entity.pos.x, entity.pos.y); + let dims: glam::Vec2 = glam::Vec2::new(size, size); + let topleft: glam::Vec2 = pos - dims / 2.0; + + let mut display_color: EntityColor = entity.color; + if display_color == EntityColor::Green + && let Some(rogue) = &entity.rogue + && elapsed_ms < rogue.until_ms + && entity.pos.distance_squared(mapped_mouse) <= entity.radius * entity.radius + { + display_color = rogue.hover_color; + } + + let texture = if entity.color == EntityColor::Bleu && entity.pacman_chase.is_some() { + assets::Texture::MrbleuLurking + } else { + display_color.texture() + }; + + self.renderer + .texture_screen(ctx, st, texture, topleft, dims); + } + } + + fn render_red_countdowns(&mut self, ctx: &context::Context, st: &mut state::State) { + self.renderer.bind_uber_2d(ctx, st, UberFlags::empty()); + self.renderer + .set_vec4(ctx, st, UI_UNIFORM_COLOR_NAME, RED_COUNTDOWN_BAR_COLOR); + + for entity in self.state.entities() { + if !entity.alive || entity.color != EntityColor::Red { + continue; + } + + if let Some(spawn_time) = entity.spawn_time_ms { + let elapsed = self.state.elapsed_ms() - spawn_time; + if elapsed < RED_COUNTDOWN_MS { + let remaining = RED_COUNTDOWN_MS - elapsed; + let fraction = remaining / RED_COUNTDOWN_MS; + let bar_width = 10.6; + let bar_height = 1.6; + let pos = glam::Vec2::new( + entity.pos.x - bar_width / 2.0, + entity.pos.y - entity.radius - 3.2, + ); + + self.renderer.set_position_2d( + ctx, + st, + pos, + glam::Vec2::new(bar_width * fraction, bar_height), + ); + self.renderer.render_square(ctx, st); + } + } + } + } + + fn render_explosions(&mut self, ctx: &context::Context, st: &mut state::State) { + self.renderer.bind_uber_2d(ctx, st, UberFlags::empty()); + + for exp in &self.explosions { + let t = exp.age_ms / exp.duration_ms; + let alpha = 1.0 - t; + let r = exp.radius * (0.5 + t * 0.5); + let color = glam::Vec4::new(1.0, (200.0 - t * 200.0) / 255.0, 0.0, alpha); + self.renderer.set_vec4(ctx, st, "color", color); + + let size = r * 2.0; + let pos = glam::Vec2::new(exp.x - r, exp.y - r); + self.renderer + .set_position_2d(ctx, st, pos, glam::Vec2::new(size, size)); + self.renderer.render_square(ctx, st); + } + } + + fn render_flash(&mut self, ctx: &context::Context, st: &mut state::State) { + if self.greens_wiped_flash > 0.0 { + let flash_alpha = (self.greens_wiped_flash / 300.0) * 0.3; + let flash_color = glam::Vec4::new( + FLASH_SCREEN_BASE_COLOR.x, + FLASH_SCREEN_BASE_COLOR.y, + FLASH_SCREEN_BASE_COLOR.z, + flash_alpha, + ); + self.renderer.color_screen( + ctx, + st, + flash_color, + glam::Vec2::ZERO, + glam::Vec2::new(ctx.render_width, ctx.render_height), + ); + } + } + + fn render_computer(&mut self, ctx: &context::Context, st: &mut state::State) { + let dims = glam::Vec2::new(COMPUTER_WIDTH, COMPUTER_HEIGHT); + let topleft = self.computer.coord - dims / 2.0; + self.renderer + .texture_screen(ctx, st, assets::Texture::Computer, topleft, dims); + } + + fn render_disquette(&mut self, ctx: &context::Context, st: &mut state::State) { + if self.computer.already_used() || self.computer.disquette_inserted() { + return; + } + + let size = self.computer.disquette.size; + let dims = glam::Vec2::splat(size); + let topleft = self.computer.disquette.coord - dims / 2.0; + self.renderer + .texture_screen(ctx, st, assets::Texture::Disquette, topleft, dims); + } + + fn render_hud(&mut self, ctx: &context::Context, st: &mut state::State) { + self.renderer.text_colored_screen( + ctx, + st, + HUD_SCORE_POSITION, + HUD_TEXT_COLOR, + &format!( + "{}{}", + HUD_SCORE_LABEL_PREFIX, + format_args!("{}/{}", self.state.consumer_score(), NB_GREENS) + ), + ); + + let remaining_secs = (self.state.remaining_ms() / 1000.0).ceil() as i32; + let time_str = format!( + "{}{}{}", + HUD_TIME_LABEL_PREFIX, remaining_secs, HUD_TIME_LABEL_SUFFIX + ); + let time_width = self.renderer.font_char_width(st) * time_str.len() as f32; + + self.renderer.text_colored_screen( + ctx, + st, + glam::Vec2::new(ctx.render_width - time_width - 2.6, 6.6), + HUD_TEXT_COLOR, + &time_str, + ); + + self.renderer.text_colored_screen( + ctx, + st, + HUD_FPS_POSITION, + HUD_TEXT_COLOR, + &format!("{}{}", HUD_FPS_LABEL_PREFIX, st.fps), + ); + } + + fn render_game_over(&mut self, ctx: &context::Context, st: &mut state::State) { + st.audio.stop_music(); + self.renderer.color_screen( + ctx, + st, + GAME_OVER_OVERLAY_COLOR, + glam::Vec2::ZERO, + glam::Vec2::new(ctx.render_width, ctx.render_height), + ); + + self.draw_text_centered( + ctx, + st, + ctx.render_width / 2.0, + ctx.render_height / 2.0 - 8.0, + GAME_OVER_TEXT_COLOR, + GAME_OVER_TITLE_TEXT, + ); + + let status_text: &str = self.state.round_result().into(); + + self.draw_text_centered( + ctx, + st, + ctx.render_width / 2.0, + ctx.render_height / 2.0 + 5.3, + GAME_OVER_TEXT_COLOR, + &format!("{}{}", GAME_OVER_RESULT_PREFIX, status_text), + ); + self.draw_text_centered( + ctx, + st, + ctx.render_width / 2.0, + ctx.render_height / 2.0 + 14.6, + GAME_OVER_TEXT_COLOR, + &format!( + "{}{}", + HUD_SCORE_LABEL_PREFIX, + format_args!( + "{}/{}", + self.state.consumer_score(), + self.state.required_greens() + ) + ), + ); + } +} + +impl teleia::state::Game for Game { + fn initialize(&mut self, _ctx: &context::Context, st: &mut state::State) -> Erm<()> { + log::info!("eatyourgreens:: entering initialize"); + + st.keybindings.insert( + state::Keycode::new(winit::keyboard::KeyCode::ArrowUp), + state::Key::Up, + ); + st.keybindings.insert( + state::Keycode::new(winit::keyboard::KeyCode::ArrowDown), + state::Key::Down, + ); + st.keybindings.insert( + state::Keycode::new(winit::keyboard::KeyCode::ArrowLeft), + state::Key::Left, + ); + st.keybindings.insert( + state::Keycode::new(winit::keyboard::KeyCode::ArrowRight), + state::Key::Right, + ); + + log::info!("eatyourgreens:: exiting initialize"); + Ok(()) + } + + fn initialize_audio( + &self, + _ctx: &context::Context, + _st: &state::State, + actx: &audio::Context, + ) -> HashMap { + let mut map = HashMap::new(); + for (name, data) in AUDIO_ASSETS { + map.insert(name.to_string(), audio::Audio::new(actx, data)); + } + map + } + + fn mouse_move( + &mut self, + _ctx: &context::Context, + _st: &mut state::State, + x: i32, + y: i32, + ) -> Erm<()> { + self.mouse = glam::Vec2::new(x as f32, y as f32); + Ok(()) + } + + fn mouse_press(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { + let mapped_mouse = self.map_mouse_to_render(ctx, st); + /*log::info!( + "eatyourgreens:: mouse at ({:?}), mapped to ({:?})", + self.mouse, + mapped_mouse + );*/ + + if !self.computer.already_used() + && self.computer.disquette_inserted() + && let Some(action) = self + .computer + .check_power_button(mapped_mouse, self.state.difficulty) + { + log::info!("eatyourgreens:: clicked power button {:?}", action); + if action == PowerButtonAction::On { + self.computer.boot(); + self.state + .set_phase(GamePhase::Computer(ComputerPhase::Booting)); + Os::boot(&mut self.state.rng); + //log::info!("eatyourgreens:: blah2"); + } + } + + if let Some(rect) = self.mute_button_rect + && rect.contains(mapped_mouse) + { + self.is_muted = !self.is_muted; + if self.is_muted { + st.audio.stop_music(); + } else { + //play something + } + return Ok(()); + } + match self.state.phase() { + GamePhase::Waiting => { + if let Some(rect) = self.start_button_rect + && rect.contains(mapped_mouse) + { + self.reset_round(ctx, st); + } + } + GamePhase::Playing => { + if let Some(ClickOutcome::Hit(color)) = + self.state.click_at(mapped_mouse.x, mapped_mouse.y) + { + log::info!("eatyourgreens:: Hit entity of color {:?}", color); + match color { + EntityColor::Green => { + if !self.is_muted { + st.audio.play_sfx("crunch"); + } + } + _ => { + if !self.is_muted { + st.audio.play_sfx("fart"); + } + } + } + } + } + GamePhase::Finished => { + if !self.is_embedded { + self.reset_round(ctx, st); + if !self.is_muted { + st.audio.stop_music(); + } + } + } + GamePhase::Computer(_) => { + //nothing to do here we yielded to DA COMPUTTTA + } + #[allow(unreachable_patterns)] + _ => unreachable!(), + } + + Ok(()) + } + + fn update(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { + let now_ms = teleia::state::now(ctx) * 1000.0; + let last_time = self.last_time_ms.unwrap_or(now_ms); + self.last_time_ms = Some(now_ms); + + if let GamePhase::Computer(ComputerPhase::On) = self.state.phase() { + // @TODO: play OS microgame here. For now, simulate its end immediately. + self.receive_op(ctx, st, "shutdown".to_string()); + } + + if self.state.phase() != GamePhase::Playing && self.state.phase() != GamePhase::Finished { + return Ok(()); + } + + if self.state.phase() == GamePhase::Playing { + // cap maximum time jump to 250ms, + // sometime the game lags when embedded in a Wcolonq wrapper, not sure why + // probbaly these copypasta all over the place make's it buggy + let real_delta = (now_ms - last_time).clamp(0.0, 250.0); + let dt = real_delta as f32; + + let disquette_pos = if !self.computer.disquette.is_inserted() { + Some(self.computer.disquette.coord) + } else { + None + }; + self.state.update_entities( + dt, + ctx.render_width, + ctx.render_height, + self.computer.disquette.is_inserted(), + disquette_pos, + ); + self.state.advance_time(dt); + self.update_explosions(dt); + + if self.greens_wiped_flash > 0.0 { + self.greens_wiped_flash = (self.greens_wiped_flash - dt).max(0.0); + } + + let events = self.state.drain_events(); + let mut disquette_consumed = false; + for event in events { + if event == "disquette_consumed" { + disquette_consumed = true; + } else { + self.handle_state_event(event, st); + } + } + + if disquette_consumed { + let hitzone = self.computer.hit_zone(self.state.difficulty); + self.computer.disquette = crate::disquette::Disquette::new( + ctx.render_width, + ctx.render_height, + &mut self.state.rng, + Some(hitzone), + ); + } + + if !self.computer.disquette.is_inserted() { + let hitzone = self.computer.hit_zone(self.state.difficulty); + self.computer.disquette.update( + ctx.render_width, + ctx.render_height, + &st.keys, + dt, + self.state.difficulty, + Some(hitzone), + ); + + if self + .computer + .contains_disquette(&self.computer.disquette, self.state.difficulty) + { + self.computer.disquette.phase = DisquettePhase::Inserted; + } + } + } + + if self.state.phase() == GamePhase::Finished && self.is_embedded && !self.done_sent { + let finish_at = *self.finish_time_ms.get_or_insert(now_ms); + + if now_ms - finish_at >= RESULT_DISPLAY_DELAY_MS { + self.send_op( + "done", + None, + Some(self.state.round_result().into()), + Some(self.state.consumer_score()), + None, + ); + self.done_sent = true; + } + } + + Ok(()) + } + + fn render(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { + if self.state.phase() == GamePhase::Waiting { + self.render_title(ctx, st); + return Ok(()); + } + + self.render_background(ctx, st); + + self.draw_mute_icon(ctx, st); + self.render_computer(ctx, st); + self.render_entities(ctx, st); + self.render_red_countdowns(ctx, st); + self.render_explosions(ctx, st); + self.render_flash(ctx, st); + self.render_disquette(ctx, st); + + self.render_hud(ctx, st); + + if self.state.phase() == GamePhase::Finished { + self.render_game_over(ctx, st); + } + + Ok(()) + } +} + +impl Game { + fn draw_text_centered( + &mut self, + ctx: &context::Context, + st: &mut state::State, + center_x: f32, + y: f32, + color: glam::Vec3, + text: &str, + ) { + let w = self.renderer.font_char_width(st) * text.len() as f32; + let x = (center_x - w / 2.0).round(); + self.renderer + .text_colored_screen(ctx, st, glam::Vec2::new(x, y), color, text); + } + + fn draw_button_centered( + &mut self, + ctx: &context::Context, + st: &mut state::State, + center_x: f32, + y: f32, + color: glam::Vec3, + text: &str, + ) { + let text_width = self.renderer.font_char_width(st) * text.len() as f32; + let padding_x = 4.8; + let padding_y = 3.2; + let char_height = self.renderer.font_char_height(st); + let height = char_height + padding_y * 2.0; + let width = text_width + padding_x * 2.0; + let x = (center_x - width / 2.0).round(); + + let border_thickness = 1.0; + let inner_color = BUTTON_FILL_COLOR; + + self.renderer.color_screen( + ctx, + st, + inner_color, + glam::Vec2::new(x + border_thickness, y + border_thickness), + glam::Vec2::new( + width - border_thickness * 2.0, + height - border_thickness * 2.0, + ), + ); + + self.draw_text_centered( + ctx, + st, + center_x, + y + (height - char_height) / 2.0, + color, + text, + ); + + self.start_button_rect = Some(Rectangle::from_xywh(x, y, width, height)); + } + + fn draw_mute_icon(&mut self, ctx: &context::Context, st: &mut state::State) { + let tex = if self.is_muted { + assets::Texture::Mute + } else { + assets::Texture::Unmute + }; + + let size = glam::Vec2::new(MUTE_ICON_SIZE, MUTE_ICON_SIZE); + let topleft = glam::Vec2::new( + MUTE_ICON_MARGIN, + ctx.render_height - MUTE_ICON_SIZE - MUTE_ICON_MARGIN, + ); + self.renderer.texture_screen(ctx, st, tex, topleft, size); + + self.mute_button_rect = Some(Rectangle::from_xywh(topleft.x, topleft.y, size.x, size.y)); + } + + // mapping of raw physical mouse coordinates to logical internal rendering coordinates. + // 1) subtract screen offsets to adjust to window margins. + // 2) scale the position by the ratio of the logical render resolution to the + // physical screen dimensions. + // 3) round and clamp the result point to guarantee it falls strictly within + // the valid rendering bounds [0, render_width] and [0, render_height]. + fn map_mouse_to_render(&self, ctx: &context::Context, st: &state::State) -> glam::Vec2 { + let x = + ((self.mouse.x - st.screen.offsets.x) * ctx.render_width / st.screen.dims.x).round(); + let y = + ((self.mouse.y - st.screen.offsets.y) * ctx.render_height / st.screen.dims.y).round(); + glam::Vec2::new( + x.clamp(0.0, ctx.render_width), + y.clamp(0.0, ctx.render_height), + ) + } + + pub(crate) fn send_op( + &self, + op: &str, + verb: Option<&str>, + win: Option, + score: Option, + video_url: Option, + ) { + // we send_op only if embedded + if !self.is_embedded { + return; + } + if let Some(window) = web_sys::window() + && let Some(parent) = window.parent().ok().flatten() + { + log::info!( + "eatyourgreens:: send op: {} with verb {:?} win {:?} score {:?}", + op, + verb, + win, + score + ); + let msg = Message { + op, + verb, + win, + score, + video_url, + }; + Message::post(&parent, &msg); + } + } +} diff --git a/2026/games_submissions/rpc2dot0/src/lib.rs b/2026/games_submissions/rpc2dot0/src/lib.rs new file mode 100644 index 0000000..31e4d1e --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/lib.rs @@ -0,0 +1,40 @@ +use crate::constants::{HEIGHT, WIDTH}; +use wasm_bindgen::prelude::*; +mod assets; +pub(crate) mod collision; +pub(crate) mod color; +pub(crate) mod computer; +pub(crate) mod constants; +pub(crate) mod disquette; +pub(crate) mod effects; +pub(crate) mod entity; +pub(crate) mod errors; +mod game; +pub(crate) mod math; +pub(crate) mod message; +pub(crate) mod random; +pub(crate) mod state; + +#[wasm_bindgen] +pub fn main_js(is_embedded: bool) { + teleia::run( + WIDTH, + HEIGHT, + teleia::Options::FULLSCREEN_MOUSE, + move |ctx| { + let game = game::Game::new(ctx, is_embedded); + game.send_op("ready", None, None, None, None); + game + }, + ); +} + +#[wasm_bindgen] +pub fn set_difficulty_js(difficulty: f32) { + teleia::contextualize(|_ctx, _st, game: &mut game::Game| game.set_difficulty(difficulty)) +} + +#[wasm_bindgen] +pub fn receive_op_js(op: String) { + teleia::contextualize(|ctx, st, game: &mut game::Game| game.receive_op(ctx, st, op.clone())) +} diff --git a/2026/games_submissions/rpc2dot0/src/math.rs b/2026/games_submissions/rpc2dot0/src/math.rs new file mode 100644 index 0000000..555204f --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/math.rs @@ -0,0 +1,55 @@ +pub use glam::Vec2; + +pub trait BoundingCircle { + fn center(&self) -> Vec2; + fn radius(&self) -> f32; + + // Intersection between two bounding circles. + // compares squared distance to squared sum of radiuSUS (*insert mrGreen.png*), + // tl;dr: a naive collision detection. + fn overlaps_with(&self, other: &impl BoundingCircle) -> bool { + let radius_sum = self.radius() + other.radius(); + self.center().distance_squared(other.center()) <= radius_sum * radius_sum + } +} + +#[derive(Clone, Copy)] +pub struct Rectangle { + pub min: Vec2, + pub max: Vec2, +} + +pub trait ContainsPoint { + fn contains(&self, point: Vec2) -> bool; +} + +impl ContainsPoint for Rectangle { + fn contains(&self, point: Vec2) -> bool { + point.cmpge(self.min).all() && point.cmple(self.max).all() + } +} + +impl Rectangle { + pub const fn from_xywh(x: f32, y: f32, width: f32, height: f32) -> Self { + Self { + min: Vec2::new(x, y), + max: Vec2::new(x + width, y + height), + } + } + + pub const fn from_min_max(min: Vec2, max: Vec2) -> Self { + Self { min, max } + } + + #[allow(dead_code)] + pub fn overlaps(&self, other: &Rectangle) -> bool { + self.min.cmple(other.max).all() && self.max.cmpge(other.min).all() + } +} + +pub fn parse_coords(coords: &str) -> (f32, f32) { + let mut parts = coords.split(','); + let x: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + let y: f32 = parts.next().unwrap_or("0").parse().unwrap_or(0.0); + (x, y) +} diff --git a/2026/games_submissions/rpc2dot0/src/message.rs b/2026/games_submissions/rpc2dot0/src/message.rs new file mode 100644 index 0000000..d960b67 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/message.rs @@ -0,0 +1,26 @@ +use serde::Serialize; +use std::fmt::Debug; + +#[derive(Serialize, Debug)] +pub struct Message<'a> { + pub op: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub verb: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub win: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub score: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video_url: Option, +} + +impl<'a> Message<'a> { + pub fn post(window: &web_sys::Window, msg: &MSG) + where + MSG: Serialize + Debug, + { + log::info!("eatyourgreens:: post message: {:?}", msg); + let js_value = serde_wasm_bindgen::to_value(msg).unwrap(); + let _ = window.post_message(&js_value, "*"); + } +} diff --git a/2026/games_submissions/rpc2dot0/src/random.rs b/2026/games_submissions/rpc2dot0/src/random.rs new file mode 100644 index 0000000..14a9f78 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/random.rs @@ -0,0 +1,107 @@ +use crate::color::EntityColor; +use crate::constants::get_baseline_ratio; +use crate::math::Vec2; + +use rand::{Rng, RngCore, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +#[derive(Debug, Clone)] +pub struct RandomState { + rng: ChaCha8Rng, +} + +impl RandomState { + /// RandomState::new(0x67676767) when debugging + pub fn new(seed: usize) -> Self { + log::info!("eatyourgreens:: RandomState new seed is {:?}", seed); + Self { + rng: ChaCha8Rng::seed_from_u64(seed as u64), + } + } + + pub fn next_u32(&mut self) -> u32 { + self.rng.next_u32() + } + + pub fn next_f32(&mut self) -> f32 { + self.rng.r#gen::() + } + + pub fn next_range(&mut self, min: f32, max: f32) -> f32 { + self.rng.gen_range(min..max) + } + + pub fn next_unit_vector(&mut self) -> Vec2 { + let angle = self.rng.gen_range(0.0..std::f32::consts::TAU); + Vec2::new(angle.cos(), angle.sin()) + } + + pub fn next_bool(&mut self, p: f64) -> bool { + self.rng.gen_bool(p) + } + + // this is used for rogue colors, + // when used for random spawn, there is a risk + // of color overcrowding. (like too much green or whatever) + pub fn random_color(&mut self) -> EntityColor { + let next_color_prob = self.next_f32(); + match next_color_prob { + x if x < 0.60 => EntityColor::Green, + x if x < 0.80 => EntityColor::Yellow, + x if x < 0.90 => EntityColor::Red, + _ => EntityColor::Bleu, + } + } + + // don't give it too much toughts this is designed by a clanker (cause too much math hurts my soul, I am not sorry) + // coded by yours truely something within the spirit of + // wiki : + // https://en.wikipedia.org/wiki/Fitness_proportionate_selection + // https://en.wikipedia.org/wiki/Reservoir_sampling#Weighted_random_sampling + + // we try to have a basline that is adjustable by difficulty + // then we have weights for our biased random picker + // used to spanwing entities + // seed compatiable aka : reproducible behaviour with a fixed D and S (D=difficulty, S=seed) + /* + difficulty 1.0 => Green: 0.60 Red: 0.10 (baseline, no shift) + difficulty 1.5 => Green: 0.525 Red: 0.175 (shift = 0.075) + difficulty 2.0 => Green: 0.45 Red: 0.25 (shift = 0.15) + difficulty 3.0 => Green: 0.30 Red: 0.40 (shift = 0.30) + difficulty 5.0 => Green: 0.05 Red: 0.65 (capped: Green can't go below 5%) + */ + // not tested and I won't test it, fuck that. + pub fn random_color_biased(&mut self, counts: [u32; 4], difficulty: f32) -> EntityColor { + let baseline: [f32; 4] = { + let mut b = [0.0f32; 4]; + b[EntityColor::Red as usize] = get_baseline_ratio(EntityColor::Red, difficulty); + b[EntityColor::Green as usize] = get_baseline_ratio(EntityColor::Green, difficulty); + b[EntityColor::Bleu as usize] = get_baseline_ratio(EntityColor::Bleu, difficulty); + b[EntityColor::Yellow as usize] = get_baseline_ratio(EntityColor::Yellow, difficulty); + b + }; + + let total = counts.iter().sum::().max(1) as f32; + + let mut weights = [0.0f32; 4]; + for i in 0..4 { + let actual_ratio = counts[i] as f32 / total; + weights[i] = (2.0 * baseline[i] - actual_ratio).max(0.05); + } + let sum: f32 = weights.iter().sum(); + let roll: f32 = self.next_f32() * sum; + let mut cumulative: f32 = 0.0f32; + for (i, &w) in weights.iter().enumerate() { + cumulative += w; + if roll < cumulative { + return match i { + 0 => EntityColor::Red, + 1 => EntityColor::Green, + 2 => EntityColor::Bleu, + _ => EntityColor::Yellow, + }; + } + } + unreachable!("Should never happen") + } +} diff --git a/2026/games_submissions/rpc2dot0/src/state.rs b/2026/games_submissions/rpc2dot0/src/state.rs new file mode 100644 index 0000000..c084bfc --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/state.rs @@ -0,0 +1,484 @@ +use crate::collision::process_collisions; +use crate::color::EntityColor; +use crate::computer::ComputerPhase; +use crate::constants::{ + BASE_SPEED, ENTITY_RADIUS, ENTITY_SCALE_FACTOR, GAME_DURATION_MS, INITIAL_SPAWN, + INITIAL_SPAWN_SPREAD_MS, JITTER_STRENGTH, MAX_ENTITIES, MAX_SPEED, PACMAN_CHASE_PROB, + PACMAN_CHASE_SPEED_MULT, RED_COUNTDOWN_MS, ROGUE_DURATION_MS, SPAWN_PADDING, SPAWN_RATE_MAX_MS, + SPAWN_RATE_MIN_MS, +}; +use crate::effects::{PacmanChase, Rogue}; +use crate::entity::{Entity, Update}; +use crate::errors::ClickOutcome; +use crate::math::Vec2; +use crate::random::RandomState; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum GamePhase { + Waiting, + Playing, + Computer(ComputerPhase), + Finished, +} + +impl GamePhase { + #[allow(dead_code)] + pub fn phase(&self) -> &'static str { + match self { + GamePhase::Waiting => "waiting", + GamePhase::Playing => "playing", + GamePhase::Computer(_) => "computer", + GamePhase::Finished => "finished", + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum RoundResult { + Ok, + Ko, +} + +impl From for &'static str { + fn from(value: RoundResult) -> Self { + match value { + RoundResult::Ok => "Ok", + RoundResult::Ko => "Ko", + } + } +} + +impl From for bool { + fn from(value: RoundResult) -> Self { + match value { + RoundResult::Ok => true, + RoundResult::Ko => false, + } + } +} + +#[derive(Debug, Clone)] +pub struct GameState { + consumer_score: i32, + pub(crate) elapsed_ms: f32, + phase: GamePhase, + pub(crate) entities: Vec, + pub(crate) next_entity_id: u32, + pub(crate) rng: RandomState, + spawn_timer_ms: f32, + initial_spawn_remaining: u32, + next_spawn_delay_ms: f32, + pub(crate) collision_buffer: Vec<(usize, usize)>, + pub(crate) events: Vec, + pub(crate) difficulty: f32, +} + +impl Default for GameState { + fn default() -> Self { + Self::new() + } +} + +impl GameState { + pub fn new() -> Self { + Self { + consumer_score: 0, + elapsed_ms: 0.0, + phase: GamePhase::Waiting, + entities: Vec::new(), + next_entity_id: 1, + #[cfg(debug_assertions)] + rng: RandomState::new(0x67676767), + #[cfg(not(debug_assertions))] + rng: RandomState::new((js_sys::Date::now() as u64) as usize), + spawn_timer_ms: 0.0, + initial_spawn_remaining: INITIAL_SPAWN, + next_spawn_delay_ms: (INITIAL_SPAWN_SPREAD_MS / INITIAL_SPAWN as f32).max(1.0), + collision_buffer: Vec::new(), + events: Vec::new(), + difficulty: 1.0, + } + } + + pub fn set_difficulty(&mut self, difficulty: f32) { + self.difficulty = difficulty; + } + + pub fn game_duration_ms(&self) -> f32 { + GAME_DURATION_MS as f32 / self.difficulty + } + + pub fn required_greens(&self) -> i32 { + 5.max((15.0 / self.difficulty.sqrt()).round() as i32) + } + + pub const fn base_speed(&self) -> f32 { + BASE_SPEED * self.difficulty + } + + pub fn max_speed(&self) -> f32 { + MAX_SPEED * self.difficulty + } + + pub fn spawn_rate_min_ms(&self) -> u32 { + (SPAWN_RATE_MIN_MS as f32 / self.difficulty) as u32 + } + + pub fn spawn_rate_max_ms(&self) -> u32 { + (SPAWN_RATE_MAX_MS as f32 / self.difficulty) as u32 + } + + pub fn entity_radius(&self) -> f32 { + ENTITY_RADIUS / (1.0 + (self.difficulty - 1.0) * 0.25) + } + + pub fn start_round(&mut self) { + self.consumer_score = 0; + self.elapsed_ms = 0.0; + self.phase = GamePhase::Playing; + self.entities.clear(); + self.next_entity_id = 1; + self.spawn_timer_ms = 0.0; + self.initial_spawn_remaining = INITIAL_SPAWN; + self.next_spawn_delay_ms = (INITIAL_SPAWN_SPREAD_MS / INITIAL_SPAWN as f32).max(1.0); + self.events.clear(); + } + pub fn set_phase(&mut self, phase: GamePhase) { + self.phase = phase; + } + /* + pub fn boot_computer(&mut self) { + if self.phase == GamePhase::Playing { + self.phase = GamePhase::Computer(ComputerPhase::Booting); + } + } + + pub fn shutdown_computer(&mut self) { + if self.phase == GamePhase::Computer(ComputerPhase::On) { + self.phase = GamePhase::Computer(ComputerPhase::ShutDown); + } + } + */ + + pub fn spawn_entity( + &mut self, + pos: Vec2, + vel: Vec2, + color: EntityColor, + disquette_inserted: bool, + ) { + if self.get_entity_count() >= MAX_ENTITIES { + return; + } + let speed = self.base_speed(); + let radius = self.entity_radius() * ENTITY_SCALE_FACTOR; + let mut entity = Entity::new(self.next_entity_id, pos, vel, color, speed, radius); + if color == EntityColor::Red { + entity.spawn_time_ms = Some(self.elapsed_ms); + } + if color == EntityColor::Green { + let base_prob = 0.25; + let prob = (base_prob * self.difficulty).min(0.85); + if self.rng.next_bool(prob as f64) { + let rogue_duration = ROGUE_DURATION_MS / self.difficulty; + let mut hover_color = self.rng.random_color(); + while hover_color == EntityColor::Green { + hover_color = self.rng.random_color(); + } + entity.rogue = Some(Rogue::new(self.elapsed_ms + rogue_duration, hover_color)); + } + } + + if !disquette_inserted + && color == EntityColor::Bleu + && self.rng.next_bool(PACMAN_CHASE_PROB) + { + let chase_speed = self.base_speed() * PACMAN_CHASE_SPEED_MULT; + entity.pacman_chase = Some(PacmanChase::new(chase_speed)); + } + self.next_entity_id = self.next_entity_id.wrapping_add(1); + self.entities.push(entity); + } + pub fn update_entities( + &mut self, + delta_ms: f32, + area_width: f32, + area_height: f32, + disquette_inserted: bool, + disquette_pos: Option, + ) { + self.update_spawning(delta_ms, area_width, area_height, disquette_inserted); + + let current_max_speed = self.max_speed(); + for entity in &mut self.entities { + if !entity.alive { + continue; + } + if let Some(chase) = &entity.pacman_chase { + if let Some(target) = disquette_pos { + let dx = target.x - entity.pos.x; + let dy = target.y - entity.pos.y; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > f32::EPSILON { + entity.vel.x = (dx / dist) * chase.speed; + entity.vel.y = (dy / dist) * chase.speed; + } + } else { + entity.apply_jitter(Vec2::new( + self.rng.next_range(-JITTER_STRENGTH, JITTER_STRENGTH), + self.rng.next_range(-JITTER_STRENGTH, JITTER_STRENGTH), + )); + } + } else { + entity.apply_jitter(Vec2::new( + self.rng.next_range(-JITTER_STRENGTH, JITTER_STRENGTH), + self.rng.next_range(-JITTER_STRENGTH, JITTER_STRENGTH), + )); + } + + entity.update(delta_ms); + entity.dampen_speed(current_max_speed); + entity.bounds_bounce(area_width, area_height); + } + if let Some(target) = disquette_pos { + const DISQUETTE_HALF_SIZE: f32 = 3.2; + let mut consumed = false; + for entity in &self.entities { + if entity.alive + && entity.pacman_chase.is_some() + && entity.pos.distance_squared(target) + <= (entity.radius + DISQUETTE_HALF_SIZE).powi(2) + { + consumed = true; + break; + } + } + if consumed { + self.events.push("disquette_consumed".to_string()); + } + } + + process_collisions( + &mut self.entities, + &mut self.rng, + &mut self.next_entity_id, + &mut self.collision_buffer, + self.elapsed_ms, + &mut self.events, + ); + + // Check red countdown timers (explode if expired) + let mut greens_to_kill = false; + for entity in &mut self.entities { + if entity.alive + && entity.color == EntityColor::Red + && let Some(spawn_time) = entity.spawn_time_ms + && self.elapsed_ms >= spawn_time + RED_COUNTDOWN_MS + { + entity.alive = false; + self.events + .push(format!("explode:{},{}", entity.pos.x, entity.pos.y)); + greens_to_kill = true; + } + } + if greens_to_kill { + for entity in &mut self.entities { + if entity.alive && entity.color == EntityColor::Green { + entity.alive = false; + } + } + } + + self.entities.retain(|e| e.alive); + } + + pub fn entities(&self) -> &[Entity] { + &self.entities + } + + fn schedule_next_spawn(&mut self) { + self.next_spawn_delay_ms = if self.initial_spawn_remaining > 0 { + (INITIAL_SPAWN_SPREAD_MS / INITIAL_SPAWN as f32).max(1.0) + } else { + self.rng + .next_range( + self.spawn_rate_min_ms() as f32, + self.spawn_rate_max_ms() as f32, + ) + .round() + .max(1.0) + }; + } + + fn position_overlaps(&self, pos: Vec2) -> bool { + let scaled_radius = self.entity_radius() * ENTITY_SCALE_FACTOR; + self.entities.iter().any(|entity| { + let distance_sq = entity.pos.distance_squared(pos); + distance_sq < (entity.radius + scaled_radius + SPAWN_PADDING).powi(2) + }) + } + + fn generate_spawn_position(&mut self, area_width: f32, area_height: f32) -> Vec2 { + let scaled_radius = self.entity_radius() * ENTITY_SCALE_FACTOR; + const MAX_ATTEMPTS: usize = 16; + for _ in 0..MAX_ATTEMPTS { + let pos = Vec2::new( + self.rng + .next_range(scaled_radius, area_width - scaled_radius), + self.rng + .next_range(scaled_radius, area_height - scaled_radius), + ); + if !self.position_overlaps(pos) { + return pos; + } + } + Vec2::new( + self.rng + .next_range(scaled_radius, area_width - scaled_radius), + self.rng + .next_range(scaled_radius, area_height - scaled_radius), + ) + } + + fn spawn_random_entity(&mut self, area_width: f32, area_height: f32, disquette_inserted: bool) { + if self.get_entity_count() >= MAX_ENTITIES { + return; + } + + let pos = self.generate_spawn_position(area_width, area_height); + let dir = self.rng.next_unit_vector(); + let vel = dir * self.base_speed(); + + let mut color_counts = [0u32; 4]; + for entity in &self.entities { + if entity.alive { + color_counts[entity.color as usize] += 1; + } + } + let color = self.rng.random_color_biased(color_counts, self.difficulty); + self.spawn_entity(pos, vel, color, disquette_inserted); + } + + fn update_spawning( + &mut self, + delta_ms: f32, + area_width: f32, + area_height: f32, + disquette_inserted: bool, + ) { + if self.phase != GamePhase::Playing || area_width <= 0.0 || area_height <= 0.0 { + return; + } + + self.spawn_timer_ms += delta_ms; + while self.spawn_timer_ms >= self.next_spawn_delay_ms { + self.spawn_timer_ms -= self.next_spawn_delay_ms; + if self.get_entity_count() >= MAX_ENTITIES { + self.spawn_timer_ms = 0.0; + break; + } + + if self.initial_spawn_remaining > 0 { + self.spawn_random_entity(area_width, area_height, disquette_inserted); + self.initial_spawn_remaining = self.initial_spawn_remaining.saturating_sub(1); + } else { + self.spawn_random_entity(area_width, area_height, disquette_inserted); + } + + self.schedule_next_spawn(); + } + } + + fn entity_at_point(&self, point: Vec2) -> Option { + self.entities + .iter() + .enumerate() + .rev() + .find(|(_, e)| e.alive && e.pos.distance_squared(point) <= e.radius * e.radius) + .map(|(idx, _)| idx) + } + + pub fn click_at(&mut self, x: f32, y: f32) -> Option { + //log::info!("eatyourgreens:: click_at(x={x},y={y})"); + if self.phase != GamePhase::Playing { + return None; + } + + let target = self.entity_at_point(Vec2::new(x, y)); + if let Some(idx) = target { + let color = self.entities[idx].color; + + let mut effective_color = color; + if color == EntityColor::Green + && let Some(rogue) = &self.entities[idx].rogue + && self.elapsed_ms < rogue.until_ms + { + effective_color = rogue.hover_color; + } + + self.entities[idx].alive = false; + match effective_color { + EntityColor::Green => self.change_score(1), + EntityColor::Yellow | EntityColor::Red | EntityColor::Bleu => self.change_score(-1), + } + + if effective_color == EntityColor::Red { + self.events.push(format!("click_red:{},{}", x, y)); + } + return Some(ClickOutcome::Hit(effective_color)); + } + Some(ClickOutcome::Miss) + } + + pub fn consumer_score(&self) -> i32 { + self.consumer_score + } + + pub fn elapsed_ms(&self) -> f32 { + self.elapsed_ms + } + + pub fn remaining_ms(&self) -> f32 { + self.game_duration_ms() - self.elapsed_ms + } + + pub fn phase(&self) -> GamePhase { + self.phase + } + + pub fn round_result(&self) -> RoundResult { + if self.consumer_score >= self.required_greens() { + RoundResult::Ok + } else { + RoundResult::Ko + } + } + + pub fn advance_time(&mut self, delta_ms: f32) { + if self.phase != GamePhase::Playing { + return; + } + + self.elapsed_ms += delta_ms; + let duration = self.game_duration_ms(); + if self.elapsed_ms >= duration { + self.elapsed_ms = duration; + self.phase = GamePhase::Finished; + } + } + + pub fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn get_entity_count(&self) -> u32 { + self.entities.iter().filter(|e| e.alive).count() as u32 + } + + pub fn change_score(&mut self, delta: i32) { + if self.phase != GamePhase::Playing { + return; + } + self.consumer_score = self.consumer_score.saturating_add(delta); + } +} -- cgit v1.3.1