summaryrefslogtreecommitdiff
path: root/2026/games/rpc2dot0/src/game.rs
diff options
context:
space:
mode:
authorLLLL Colonq <llll@colonq>2026-08-29 04:06:51 -0400
committerLLLL Colonq <llll@colonq>2026-08-29 04:06:51 -0400
commit761a3172bf4c15aed3836e4d1cd6f815b651b0e0 (patch)
treecc90491529e83349d84d42a8e9982e5f0f38338e /2026/games/rpc2dot0/src/game.rs
parent0d2d8cd897e94bd94e0b3ae7c6ce9aa580a44a0a (diff)
Fix
Diffstat (limited to '2026/games/rpc2dot0/src/game.rs')
-rw-r--r--2026/games/rpc2dot0/src/game.rs869
1 files changed, 869 insertions, 0 deletions
diff --git a/2026/games/rpc2dot0/src/game.rs b/2026/games/rpc2dot0/src/game.rs
new file mode 100644
index 0000000..7650e72
--- /dev/null
+++ b/2026/games/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);
+ }
+ }
+}