use std::collections::HashMap; use teleia::renderer::UberFlags; use teleia::*; use crate::assets; use crate::assets::AUDIO_ASSETS; use crate::color::{ColorExt, EntityColor}; use crate::computer::os::Os; use crate::computer::*; use crate::constants::*; use crate::disquette::*; use crate::effects::*; use crate::errors::*; use crate::math::*; use crate::message::Message; use crate::state::{GamePhase, GameState}; pub struct Game { renderer: renderer::Renderer, state: GameState, mouse: glam::Vec2, explosions: Vec, greens_wiped_flash: f32, computer: Computer, start_button_rect: Option, is_muted: bool, mute_button_rect: Option, is_embedded: bool, last_time_ms: Option, finish_time_ms: Option, done_sent: bool, } impl Game { pub fn new(ctx: &context::Context, is_embedded: bool) -> Self { log::info!("eatyourgreens:: entering new Game"); let mut state = GameState::new(); let computer = Computer::new( ctx.render_width, ctx.render_height, &mut state.rng, state.difficulty, ); Self { renderer: renderer::Renderer::new(ctx, assets::Assets::new), state, mouse: glam::Vec2::ZERO, explosions: Vec::new(), greens_wiped_flash: 0.0, computer, start_button_rect: None, is_muted: false, mute_button_rect: None, is_embedded, last_time_ms: None, finish_time_ms: None, done_sent: false, } } pub fn set_difficulty(&mut self, difficulty: f32) { log::info!("eatyourgreens:: set_difficulty: {}", difficulty); self.state.set_difficulty(difficulty); } pub fn receive_op(&mut self, ctx: &context::Context, st: &mut state::State, op: String) { log::info!( "eatyourgreens:: receive op: {} (phase: {:?})", op, self.state.phase() ); match self.state.phase() { GamePhase::Waiting | GamePhase::Finished => { if self.is_embedded && op == "start" { self.reset_round(ctx, st); const VERB: &str = "eat your greens"; self.send_op("started", Some(VERB), None, None, None); } else { log::warn!("eatyourgreens:: received unexpected op: {} (waiting) ", op); } } GamePhase::Computer(computer_phase) => { let currentop = op.as_str(); match computer_phase { ComputerPhase::Booting => { if currentop == "video_finished" { self.state.set_phase(GamePhase::Computer(ComputerPhase::On)); self.computer.on(ctx); } else { log::warn!( "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", op, computer_phase ); } } ComputerPhase::On => { if currentop == "computing" { // nothing to do in here // we wait for os-micro-game to send shutdown log::info!("eatyourgreens:: computer computing. do nothing."); } else if currentop == "shutdown" { log::info!( "eatyourgreens:: computer shutdown. Transition to main game." ); self.computer.shutdown(); self.state.set_phase(GamePhase::Playing); } else { log::warn!( "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", op, computer_phase ); } } _ => { // we shouldn't be receiving events while computer is off or shutdown log::warn!( "eatyourgreens:: received unexpected op: {}, Phase {:?} (Computer)", op, computer_phase ); } } } _ => { log::warn!( "eatyourgreens:: received unexpected op: {} for phase: {:?}", op, self.state.phase() ); } } } fn reset_round(&mut self, ctx: &context::Context, st: &mut state::State) { log::info!("eatyourgreens:: reset_round"); self.state.start_round(); let computer = Computer::new( ctx.render_width, ctx.render_height, &mut self.state.rng, self.state.difficulty, ); self.computer = computer; self.explosions.clear(); self.greens_wiped_flash = 0.0; if self.is_muted { st.audio.stop_music(); } self.last_time_ms = None; self.finish_time_ms = None; self.done_sent = false; } fn push_explosion(&mut self, x: f32, y: f32) { self.explosions.push(Explosion::new(x, y)); } fn update_explosions(&mut self, delta_ms: f32) { for exp in &mut self.explosions { exp.age_ms += delta_ms; } self.explosions.retain(|e| e.age_ms < e.duration_ms); } fn handle_state_event(&mut self, event: String, st: &mut state::State) { log::info!("eatyourgreens:: handle_state_event: {}", event); if let Some(coords) = event.strip_prefix("explode:") { if !self.is_muted { st.audio.play_sfx("explosion"); } let (x, y): (f32, f32) = parse_coords(coords); self.push_explosion(x, y); self.greens_wiped_flash = 300.0; return; } if let Some(coords) = event.strip_prefix("click_red:") { if !self.is_muted { st.audio.play_sfx("explosion"); } let (x, y) = parse_coords(coords); self.push_explosion(x, y); return; } } fn render_title(&mut self, ctx: &context::Context, st: &mut state::State) { self.renderer .bind_uber_2d(ctx, st, UberFlags::TEXTURE_COLOR | UberFlags::SPRITE); self.renderer .bind_texture(ctx, st, assets::Texture::Landing); self.renderer.set_texture_offset( ctx, st, 1, 1, LANDING_TEXTURE_WIDTH, LANDING_TEXTURE_HEIGHT, ); self.renderer .set_position_2d(ctx, st, glam::Vec2::ZERO, st.render_dims); self.renderer.set_f32(ctx, st, UI_UNIFORM_OPACITY_NAME, 0.5); self.renderer.render_square(ctx, st); let center_x: f32 = ctx.render_width / 2.0; let mut y: f32 = ctx.render_height * 0.3; let line_height: f32 = self.renderer.font_char_height(st) + 1.0; self.draw_text_centered( ctx, st, center_x, y, TITLE_HEADING_TEXT_COLOR, &title_score_goal(), ); y += line_height; self.draw_text_centered( ctx, st, center_x, y, TITLE_HEADING_TEXT_COLOR, TITLE_CLICK_START_HINT, ); y += line_height * 2.0; if !self.is_embedded { self.draw_button_centered( ctx, st, center_x, y, START_BUTTON_TEXT_COLOR, TITLE_START_BUTTON_LABEL, ); } else { self.start_button_rect = None; } y += line_height * 3.0; self.draw_text_centered( ctx, st, center_x, y, TITLE_DESCRIPTION_TEXT_COLOR, TITLE_SCORING_HINT, ); y += line_height; self.draw_text_centered( ctx, st, center_x, y, TITLE_DESCRIPTION_TEXT_COLOR, TITLE_HINT, ); self.draw_mute_icon(ctx, st); } fn render_background(&mut self, ctx: &context::Context, st: &mut state::State) { ctx.clear_color(glam::Vec4::ZERO); ctx.clear(); self.renderer.texture_screen( ctx, st, assets::Texture::BackgroundMain, glam::Vec2::ZERO, glam::Vec2::new(ctx.render_width, ctx.render_height), ); } fn render_entities(&mut self, ctx: &context::Context, st: &mut state::State) { let mapped_mouse = self.map_mouse_to_render(ctx, st); let elapsed_ms = self.state.elapsed_ms(); for entity in self.state.entities() { if !entity.alive { continue; } let size: f32 = entity.radius * 2.0; let pos: glam::Vec2 = glam::Vec2::new(entity.pos.x, entity.pos.y); let dims: glam::Vec2 = glam::Vec2::new(size, size); let topleft: glam::Vec2 = pos - dims / 2.0; let mut display_color: EntityColor = entity.color; if display_color == EntityColor::Green && let Some(rogue) = &entity.rogue && elapsed_ms < rogue.until_ms && entity.pos.distance_squared(mapped_mouse) <= entity.radius * entity.radius { display_color = rogue.hover_color; } let texture = if entity.color == EntityColor::Bleu && entity.pacman_chase.is_some() { assets::Texture::MrbleuLurking } else { display_color.texture() }; self.renderer .texture_screen(ctx, st, texture, topleft, dims); } } fn render_red_countdowns(&mut self, ctx: &context::Context, st: &mut state::State) { self.renderer.bind_uber_2d(ctx, st, UberFlags::empty()); self.renderer .set_vec4(ctx, st, UI_UNIFORM_COLOR_NAME, RED_COUNTDOWN_BAR_COLOR); for entity in self.state.entities() { if !entity.alive || entity.color != EntityColor::Red { continue; } if let Some(spawn_time) = entity.spawn_time_ms { let elapsed = self.state.elapsed_ms() - spawn_time; if elapsed < RED_COUNTDOWN_MS { let remaining = RED_COUNTDOWN_MS - elapsed; let fraction = remaining / RED_COUNTDOWN_MS; let bar_width = 10.6; let bar_height = 1.6; let pos = glam::Vec2::new( entity.pos.x - bar_width / 2.0, entity.pos.y - entity.radius - 3.2, ); self.renderer.set_position_2d( ctx, st, pos, glam::Vec2::new(bar_width * fraction, bar_height), ); self.renderer.render_square(ctx, st); } } } } fn render_explosions(&mut self, ctx: &context::Context, st: &mut state::State) { self.renderer.bind_uber_2d(ctx, st, UberFlags::empty()); for exp in &self.explosions { let t = exp.age_ms / exp.duration_ms; let alpha = 1.0 - t; let r = exp.radius * (0.5 + t * 0.5); let color = glam::Vec4::new(1.0, (200.0 - t * 200.0) / 255.0, 0.0, alpha); self.renderer.set_vec4(ctx, st, "color", color); let size = r * 2.0; let pos = glam::Vec2::new(exp.x - r, exp.y - r); self.renderer .set_position_2d(ctx, st, pos, glam::Vec2::new(size, size)); self.renderer.render_square(ctx, st); } } fn render_flash(&mut self, ctx: &context::Context, st: &mut state::State) { if self.greens_wiped_flash > 0.0 { let flash_alpha = (self.greens_wiped_flash / 300.0) * 0.3; let flash_color = glam::Vec4::new( FLASH_SCREEN_BASE_COLOR.x, FLASH_SCREEN_BASE_COLOR.y, FLASH_SCREEN_BASE_COLOR.z, flash_alpha, ); self.renderer.color_screen( ctx, st, flash_color, glam::Vec2::ZERO, glam::Vec2::new(ctx.render_width, ctx.render_height), ); } } fn render_computer(&mut self, ctx: &context::Context, st: &mut state::State) { let dims = glam::Vec2::new(COMPUTER_WIDTH, COMPUTER_HEIGHT); let topleft = self.computer.coord - dims / 2.0; self.renderer .texture_screen(ctx, st, assets::Texture::Computer, topleft, dims); } fn render_disquette(&mut self, ctx: &context::Context, st: &mut state::State) { if self.computer.already_used() || self.computer.disquette_inserted() { return; } let size = self.computer.disquette.size; let dims = glam::Vec2::splat(size); let topleft = self.computer.disquette.coord - dims / 2.0; self.renderer .texture_screen(ctx, st, assets::Texture::Disquette, topleft, dims); } fn render_hud(&mut self, ctx: &context::Context, st: &mut state::State) { self.renderer.text_colored_screen( ctx, st, HUD_SCORE_POSITION, HUD_TEXT_COLOR, &format!( "{}{}", HUD_SCORE_LABEL_PREFIX, format_args!("{}/{}", self.state.consumer_score(), NB_GREENS) ), ); let remaining_secs = (self.state.remaining_ms() / 1000.0).ceil() as i32; let time_str = format!( "{}{}{}", HUD_TIME_LABEL_PREFIX, remaining_secs, HUD_TIME_LABEL_SUFFIX ); let time_width = self.renderer.font_char_width(st) * time_str.len() as f32; self.renderer.text_colored_screen( ctx, st, glam::Vec2::new(ctx.render_width - time_width - 2.6, 6.6), HUD_TEXT_COLOR, &time_str, ); self.renderer.text_colored_screen( ctx, st, HUD_FPS_POSITION, HUD_TEXT_COLOR, &format!("{}{}", HUD_FPS_LABEL_PREFIX, st.fps), ); } fn render_game_over(&mut self, ctx: &context::Context, st: &mut state::State) { st.audio.stop_music(); self.renderer.color_screen( ctx, st, GAME_OVER_OVERLAY_COLOR, glam::Vec2::ZERO, glam::Vec2::new(ctx.render_width, ctx.render_height), ); self.draw_text_centered( ctx, st, ctx.render_width / 2.0, ctx.render_height / 2.0 - 8.0, GAME_OVER_TEXT_COLOR, GAME_OVER_TITLE_TEXT, ); let status_text: &str = self.state.round_result().into(); self.draw_text_centered( ctx, st, ctx.render_width / 2.0, ctx.render_height / 2.0 + 5.3, GAME_OVER_TEXT_COLOR, &format!("{}{}", GAME_OVER_RESULT_PREFIX, status_text), ); self.draw_text_centered( ctx, st, ctx.render_width / 2.0, ctx.render_height / 2.0 + 14.6, GAME_OVER_TEXT_COLOR, &format!( "{}{}", HUD_SCORE_LABEL_PREFIX, format_args!( "{}/{}", self.state.consumer_score(), self.state.required_greens() ) ), ); } } impl teleia::state::Game for Game { fn initialize(&mut self, _ctx: &context::Context, st: &mut state::State) -> Erm<()> { log::info!("eatyourgreens:: entering initialize"); st.keybindings.insert( state::Keycode::new(winit::keyboard::KeyCode::ArrowUp), state::Key::Up, ); st.keybindings.insert( state::Keycode::new(winit::keyboard::KeyCode::ArrowDown), state::Key::Down, ); st.keybindings.insert( state::Keycode::new(winit::keyboard::KeyCode::ArrowLeft), state::Key::Left, ); st.keybindings.insert( state::Keycode::new(winit::keyboard::KeyCode::ArrowRight), state::Key::Right, ); log::info!("eatyourgreens:: exiting initialize"); Ok(()) } fn initialize_audio( &self, _ctx: &context::Context, _st: &state::State, actx: &audio::Context, ) -> HashMap { let mut map = HashMap::new(); for (name, data) in AUDIO_ASSETS { map.insert(name.to_string(), audio::Audio::new(actx, data)); } map } fn mouse_move( &mut self, _ctx: &context::Context, _st: &mut state::State, x: i32, y: i32, ) -> Erm<()> { self.mouse = glam::Vec2::new(x as f32, y as f32); Ok(()) } fn mouse_press(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { let mapped_mouse = self.map_mouse_to_render(ctx, st); /*log::info!( "eatyourgreens:: mouse at ({:?}), mapped to ({:?})", self.mouse, mapped_mouse );*/ if !self.computer.already_used() && self.computer.disquette_inserted() && let Some(action) = self .computer .check_power_button(mapped_mouse, self.state.difficulty) { log::info!("eatyourgreens:: clicked power button {:?}", action); if action == PowerButtonAction::On { self.computer.boot(); self.state .set_phase(GamePhase::Computer(ComputerPhase::Booting)); Os::boot(&mut self.state.rng); //log::info!("eatyourgreens:: blah2"); } } if let Some(rect) = self.mute_button_rect && rect.contains(mapped_mouse) { self.is_muted = !self.is_muted; if self.is_muted { st.audio.stop_music(); } else { //play something } return Ok(()); } match self.state.phase() { GamePhase::Waiting => { if let Some(rect) = self.start_button_rect && rect.contains(mapped_mouse) { self.reset_round(ctx, st); } } GamePhase::Playing => { if let Some(ClickOutcome::Hit(color)) = self.state.click_at(mapped_mouse.x, mapped_mouse.y) { log::info!("eatyourgreens:: Hit entity of color {:?}", color); match color { EntityColor::Green => { if !self.is_muted { st.audio.play_sfx("crunch"); } } _ => { if !self.is_muted { st.audio.play_sfx("fart"); } } } } } GamePhase::Finished => { if !self.is_embedded { self.reset_round(ctx, st); if !self.is_muted { st.audio.stop_music(); } } } GamePhase::Computer(_) => { //nothing to do here we yielded to DA COMPUTTTA } #[allow(unreachable_patterns)] _ => unreachable!(), } Ok(()) } fn update(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { let now_ms = teleia::state::now(ctx) * 1000.0; let last_time = self.last_time_ms.unwrap_or(now_ms); self.last_time_ms = Some(now_ms); if let GamePhase::Computer(ComputerPhase::On) = self.state.phase() { // @TODO: play OS microgame here. For now, simulate its end immediately. self.receive_op(ctx, st, "shutdown".to_string()); } if self.state.phase() != GamePhase::Playing && self.state.phase() != GamePhase::Finished { return Ok(()); } if self.state.phase() == GamePhase::Playing { // cap maximum time jump to 250ms, // sometime the game lags when embedded in a Wcolonq wrapper, not sure why // probbaly these copypasta all over the place make's it buggy let real_delta = (now_ms - last_time).clamp(0.0, 250.0); let dt = real_delta as f32; let disquette_pos = if !self.computer.disquette.is_inserted() { Some(self.computer.disquette.coord) } else { None }; self.state.update_entities( dt, ctx.render_width, ctx.render_height, self.computer.disquette.is_inserted(), disquette_pos, ); self.state.advance_time(dt); self.update_explosions(dt); if self.greens_wiped_flash > 0.0 { self.greens_wiped_flash = (self.greens_wiped_flash - dt).max(0.0); } let events = self.state.drain_events(); let mut disquette_consumed = false; for event in events { if event == "disquette_consumed" { disquette_consumed = true; } else { self.handle_state_event(event, st); } } if disquette_consumed { let hitzone = self.computer.hit_zone(self.state.difficulty); self.computer.disquette = crate::disquette::Disquette::new( ctx.render_width, ctx.render_height, &mut self.state.rng, Some(hitzone), ); } if !self.computer.disquette.is_inserted() { let hitzone = self.computer.hit_zone(self.state.difficulty); self.computer.disquette.update( ctx.render_width, ctx.render_height, &st.keys, dt, self.state.difficulty, Some(hitzone), ); if self .computer .contains_disquette(&self.computer.disquette, self.state.difficulty) { self.computer.disquette.phase = DisquettePhase::Inserted; } } } if self.state.phase() == GamePhase::Finished && self.is_embedded && !self.done_sent { let finish_at = *self.finish_time_ms.get_or_insert(now_ms); if now_ms - finish_at >= RESULT_DISPLAY_DELAY_MS { self.send_op( "done", None, Some(self.state.round_result().into()), Some(self.state.consumer_score()), None, ); self.done_sent = true; } } Ok(()) } fn render(&mut self, ctx: &context::Context, st: &mut state::State) -> Erm<()> { if self.state.phase() == GamePhase::Waiting { self.render_title(ctx, st); return Ok(()); } self.render_background(ctx, st); self.draw_mute_icon(ctx, st); self.render_computer(ctx, st); self.render_entities(ctx, st); self.render_red_countdowns(ctx, st); self.render_explosions(ctx, st); self.render_flash(ctx, st); self.render_disquette(ctx, st); self.render_hud(ctx, st); if self.state.phase() == GamePhase::Finished { self.render_game_over(ctx, st); } Ok(()) } } impl Game { fn draw_text_centered( &mut self, ctx: &context::Context, st: &mut state::State, center_x: f32, y: f32, color: glam::Vec3, text: &str, ) { let w = self.renderer.font_char_width(st) * text.len() as f32; let x = (center_x - w / 2.0).round(); self.renderer .text_colored_screen(ctx, st, glam::Vec2::new(x, y), color, text); } fn draw_button_centered( &mut self, ctx: &context::Context, st: &mut state::State, center_x: f32, y: f32, color: glam::Vec3, text: &str, ) { let text_width = self.renderer.font_char_width(st) * text.len() as f32; let padding_x = 4.8; let padding_y = 3.2; let char_height = self.renderer.font_char_height(st); let height = char_height + padding_y * 2.0; let width = text_width + padding_x * 2.0; let x = (center_x - width / 2.0).round(); let border_thickness = 1.0; let inner_color = BUTTON_FILL_COLOR; self.renderer.color_screen( ctx, st, inner_color, glam::Vec2::new(x + border_thickness, y + border_thickness), glam::Vec2::new( width - border_thickness * 2.0, height - border_thickness * 2.0, ), ); self.draw_text_centered( ctx, st, center_x, y + (height - char_height) / 2.0, color, text, ); self.start_button_rect = Some(Rectangle::from_xywh(x, y, width, height)); } fn draw_mute_icon(&mut self, ctx: &context::Context, st: &mut state::State) { let tex = if self.is_muted { assets::Texture::Mute } else { assets::Texture::Unmute }; let size = glam::Vec2::new(MUTE_ICON_SIZE, MUTE_ICON_SIZE); let topleft = glam::Vec2::new( MUTE_ICON_MARGIN, ctx.render_height - MUTE_ICON_SIZE - MUTE_ICON_MARGIN, ); self.renderer.texture_screen(ctx, st, tex, topleft, size); self.mute_button_rect = Some(Rectangle::from_xywh(topleft.x, topleft.y, size.x, size.y)); } // mapping of raw physical mouse coordinates to logical internal rendering coordinates. // 1) subtract screen offsets to adjust to window margins. // 2) scale the position by the ratio of the logical render resolution to the // physical screen dimensions. // 3) round and clamp the result point to guarantee it falls strictly within // the valid rendering bounds [0, render_width] and [0, render_height]. fn map_mouse_to_render(&self, ctx: &context::Context, st: &state::State) -> glam::Vec2 { let x = ((self.mouse.x - st.screen.offsets.x) * ctx.render_width / st.screen.dims.x).round(); let y = ((self.mouse.y - st.screen.offsets.y) * ctx.render_height / st.screen.dims.y).round(); glam::Vec2::new( x.clamp(0.0, ctx.render_width), y.clamp(0.0, ctx.render_height), ) } pub(crate) fn send_op( &self, op: &str, verb: Option<&str>, win: Option, score: Option, video_url: Option, ) { // we send_op only if embedded if !self.is_embedded { return; } if let Some(window) = web_sys::window() && let Some(parent) = window.parent().ok().flatten() { log::info!( "eatyourgreens:: send op: {} with verb {:?} win {:?} score {:?}", op, verb, win, score ); let msg = Message { op, verb, win, score, video_url, }; Message::post(&parent, &msg); } } }