diff options
| author | LLLL Colonq <llll@colonq> | 2026-08-26 23:47:59 -0400 |
|---|---|---|
| committer | LLLL Colonq <llll@colonq> | 2026-08-26 23:47:59 -0400 |
| commit | 0d2d8cd897e94bd94e0b3ae7c6ce9aa580a44a0a (patch) | |
| tree | e9039d1c3aedca515d6cb499451d4b73aca4f5fd /2026/games_submissions/rpc2dot0/src | |
| parent | 242943e5b023ce40eb3be826c418a478d4672f78 (diff) | |
Add a_tension_span
Diffstat (limited to '2026/games_submissions/rpc2dot0/src')
42 files changed, 2521 insertions, 0 deletions
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 Binary files differnew file mode 100644 index 0000000..f11334f --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/audio/crunch.wav diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav Binary files differnew file mode 100644 index 0000000..c3e867f --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/audio/explosion.wav diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav Binary files differnew file mode 100644 index 0000000..ebb001d --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/audio/fart.wav diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav Binary files differnew file mode 100644 index 0000000..57bfddf --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/audio/footsteps.wav diff --git a/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav b/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav Binary files differnew file mode 100644 index 0000000..0eabe85 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/audio/test.wav 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 Binary files differnew file mode 100644 index 0000000..82b3ebe --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/background_main.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png b/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png Binary files differnew file mode 100644 index 0000000..be5c723 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/computer.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png b/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png Binary files differnew file mode 100644 index 0000000..12399f4 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/disquette.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png b/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png Binary files differnew file mode 100644 index 0000000..229e6ad --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/landing.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png Binary files differnew file mode 100644 index 0000000..d16a717 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png Binary files differnew file mode 100644 index 0000000..3217129 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mrbleu_lurking.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png Binary files differnew file mode 100644 index 0000000..2eb7210 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mrgreen.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png Binary files differnew file mode 100644 index 0000000..2c4bc18 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mrred.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png Binary files differnew file mode 100644 index 0000000..9784a8f --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mryellow.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png b/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png Binary files differnew file mode 100644 index 0000000..b4b81dd --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/mute.png diff --git a/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png b/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png Binary files differnew file mode 100644 index 0000000..278d227 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/textures/unmute.png 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 Binary files differnew file mode 100644 index 0000000..c24a0d9 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/GG_win_c.mp4 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 Binary files differnew file mode 100644 index 0000000..0c7756d --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/confused_c.mp4 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 Binary files differnew file mode 100644 index 0000000..bccb605 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/game_over_c.mp4 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 Binary files differnew file mode 100644 index 0000000..909c56a --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/hyped_c.mp4 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 Binary files differnew file mode 100644 index 0000000..38cdc53 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/pinnesse_c.mp4 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 Binary files differnew file mode 100644 index 0000000..fb9d490 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_bad.mp4 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 Binary files differnew file mode 100644 index 0000000..7aac4d5 --- /dev/null +++ b/2026/games_submissions/rpc2dot0/src/assets/video/adzzz/seks_is_gud.mp4 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<Entity>,
+ rng: &mut RandomState,
+ _next_entity_id: &mut u32,
+ collision_buffer: &mut Vec<(usize, usize)>,
+ elapsed_ms: f32,
+ _events: &mut Vec<String>,
+) {
+ 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<ComputerPhase> 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<PowerButtonAction> 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<PowerButtonAction> {
+ 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<OsState> 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<ComputerPhase> 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<DisquettePhase> 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<Rectangle>,
+ ) -> 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<Rectangle>,
+ ) {
+ 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<f32>,
+ pub rogue: Option<Rogue>,
+ pub pacman_chase: Option<PacmanChase>,
+}
+
+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<assets::Assets>,
+ state: GameState,
+ mouse: glam::Vec2,
+ explosions: Vec<Explosion>,
+ greens_wiped_flash: f32,
+ computer: Computer,
+ start_button_rect: Option<Rectangle>,
+ is_muted: bool,
+ mute_button_rect: Option<Rectangle>,
+ is_embedded: bool,
+ last_time_ms: Option<f64>,
+ finish_time_ms: Option<f64>,
+ 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<String, audio::Audio> {
+ 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<bool>,
+ score: Option<i32>,
+ video_url: Option<String>,
+ ) {
+ // 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<bool>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub score: Option<i32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub video_url: Option<String>,
+}
+
+impl<'a> Message<'a> {
+ pub fn post<MSG>(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::<f32>()
+ }
+
+ 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::<u32>().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<RoundResult> for &'static str {
+ fn from(value: RoundResult) -> Self {
+ match value {
+ RoundResult::Ok => "Ok",
+ RoundResult::Ko => "Ko",
+ }
+ }
+}
+
+impl From<RoundResult> 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<Entity>,
+ 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<String>,
+ 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<Vec2>,
+ ) {
+ 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<usize> {
+ 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<ClickOutcome> {
+ //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<String> {
+ 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);
+ }
+}
|
