From 196dfbc536f68c65714a40b92f9c127c88fb46d2 Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Wed, 26 Aug 2026 14:23:08 +0000 Subject: [PATCH 1/7] Give the graybox a plastic look and hover-before-click. I want a first visual identity we can actually evaluate: saturated glossy hexes and a matching HUD, not the muted test graybox. Hovering expand or attack now shows participating perimeters, 11/10/9 weights, committed Share, and inland-zero before the click, without changing dispatch. The 52px strip mode-switches so idle is C + click until Share, T, X, or B actually apply. Co-authored-by: Diego Carlino --- crates/game-client/src/camera.rs | 6 +- crates/game-client/src/config.rs | 23 ++ crates/game-client/src/hud.rs | 197 +++++++-- crates/game-client/src/interaction.rs | 391 +++++++++++++++++- crates/game-client/src/lobby.rs | 18 +- crates/game-client/src/main.rs | 12 +- crates/game-client/src/network.rs | 1 + crates/game-client/src/overlays.rs | 291 +++++++++++-- crates/game-client/src/screenshot.rs | 157 +++++++ crates/game-client/src/terrain.rs | 101 +++-- docs/cluster-controls.md | 21 + .../cluster-controls-v1-notes-2026-08-26.md | 25 ++ docs/playtests/cluster-controls-v1.md | 2 +- docs/v1-ui-brief.md | 16 +- docs/v1-ui-direction.md | 15 +- 15 files changed, 1154 insertions(+), 122 deletions(-) create mode 100644 crates/game-client/src/screenshot.rs create mode 100644 docs/playtests/cluster-controls-v1-notes-2026-08-26.md diff --git a/crates/game-client/src/camera.rs b/crates/game-client/src/camera.rs index 8b58ad9..d496c22 100644 --- a/crates/game-client/src/camera.rs +++ b/crates/game-client/src/camera.rs @@ -45,10 +45,10 @@ pub fn spawn_camera_and_light(mut commands: Commands) { )); commands.spawn(( - Name::new("Graybox sun"), + Name::new("Plastic key light"), DirectionalLight { - color: Color::srgb(1.0, 0.92, 0.78), - illuminance: 13_500.0, + color: Color::srgb(1.0, 0.94, 0.82), + illuminance: 22_000.0, shadow_maps_enabled: false, ..default() }, diff --git a/crates/game-client/src/config.rs b/crates/game-client/src/config.rs index ee8b29e..5b8014f 100644 --- a/crates/game-client/src/config.rs +++ b/crates/game-client/src/config.rs @@ -93,6 +93,14 @@ struct ClientArgs { /// Emit structured `[of.observe]` events to the log/console (env: `OF_OBSERVE`). #[arg(long)] observe: bool, + + /// Write one offline-fixture PNG and exit. Native only. + #[arg(long, requires = "offline")] + screenshot: Option, + + /// Scene staged before `--screenshot` captures. + #[arg(long, requires = "screenshot", value_enum, default_value_t = ScreenshotScene::Idle)] + screenshot_scene: ScreenshotScene, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -103,6 +111,15 @@ pub struct LayeredWorldOptions { pub players: u16, } +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)] +pub enum ScreenshotScene { + #[default] + Idle, + ExpandHover, + AttackHover, +} + #[derive(Resource, Clone, Debug)] pub struct ClientConfig { pub offline: bool, @@ -116,6 +133,10 @@ pub struct ClientConfig { pub auto_join: bool, /// Emit structured observe events to stderr / browser console. pub observe: bool, + #[cfg(not(target_arch = "wasm32"))] + pub screenshot_path: Option, + #[cfg(not(target_arch = "wasm32"))] + pub screenshot_scene: ScreenshotScene, } impl ClientConfig { @@ -175,6 +196,8 @@ impl ClientConfig { profile, auto_join, observe: args.observe || env_flag("OF_OBSERVE"), + screenshot_path: args.screenshot, + screenshot_scene: args.screenshot_scene, } } diff --git a/crates/game-client/src/hud.rs b/crates/game-client/src/hud.rs index 7307c3b..f5e967c 100644 --- a/crates/game-client/src/hud.rs +++ b/crates/game-client/src/hud.rs @@ -10,19 +10,19 @@ use bevy::{ }; use crate::{ - interaction::{InteractionState, OrderMode}, + interaction::{ContextualHover, InteractionState, OrderMode}, map_view::map_view_status_bundle, model::{MatchPhase, MatchView, ToastKind}, network::{ClientIntent, NetworkSet}, }; -const PANEL: Color = Color::srgba(0.035, 0.052, 0.064, 0.96); -const PANEL_SOFT: Color = Color::srgba(0.055, 0.078, 0.092, 0.94); -const LINE: Color = Color::srgba(0.42, 0.58, 0.65, 0.48); -const TEXT: Color = Color::srgb(0.88, 0.93, 0.95); -const MUTED: Color = Color::srgb(0.57, 0.68, 0.72); -const CYAN: Color = Color::srgb(0.40, 0.87, 0.91); -const CORAL: Color = Color::srgb(1.0, 0.40, 0.32); +const PANEL: Color = Color::srgba(0.12, 0.05, 0.22, 0.94); +const PANEL_SOFT: Color = Color::srgba(0.18, 0.07, 0.28, 0.92); +const LINE: Color = Color::srgba(1.0, 0.42, 0.78, 0.58); +const TEXT: Color = Color::srgb(1.0, 0.97, 0.92); +const MUTED: Color = Color::srgb(0.92, 0.78, 0.98); +const CYAN: Color = Color::srgb(0.22, 0.98, 0.92); +const CORAL: Color = Color::srgb(1.0, 0.32, 0.48); const FIELD_MANUAL: &str = concat!( "CLUSTER SELECTION\n", @@ -276,7 +276,7 @@ fn spawn_command_bar(root: &mut ChildSpawnerCommands) { )); context.spawn(( CommandContextSummary, - Text::new("LMB neutral expand · LMB enemy attack · ? manual"), + Text::new("C cluster · click after selecting · ? manual"), TextFont::from_font_size(9.5), TextColor(MUTED), Pickable::IGNORE, @@ -285,9 +285,7 @@ fn spawn_command_bar(root: &mut ChildSpawnerCommands) { bar.spawn(( CommandKeyHints, - Text::new( - "C cluster · Shift/Ctrl+C multi · Ctrl+A all · B rebalance fronts · [ / ] Share · T reshape · X stop", - ), + Text::new("C cluster"), TextFont::from_font_size(10.0), TextColor(TEXT), Node { @@ -371,7 +369,7 @@ fn spawn_bottom_bar(root: &mut ChildSpawnerCommands, mobilization: f32) { border_radius: BorderRadius::MAX, ..default() }, - BackgroundColor(Color::srgb(0.10, 0.16, 0.18)), + BackgroundColor(Color::srgb(0.28, 0.10, 0.42)), Pickable::IGNORE, )); slider @@ -401,7 +399,7 @@ fn spawn_bottom_bar(root: &mut ChildSpawnerCommands, mobilization: f32) { ..default() }, BackgroundColor(CYAN), - BorderColor::all(Color::srgb(0.84, 0.98, 1.0)), + BorderColor::all(Color::srgb(1.0, 0.92, 0.55)), Pickable::IGNORE, )); }); @@ -465,7 +463,7 @@ fn spawn_help(root: &mut ChildSpawnerCommands) { ..default() }, GlobalZIndex(40), - BackgroundColor(Color::srgba(0.025, 0.039, 0.049, 0.985)), + BackgroundColor(Color::srgba(0.10, 0.04, 0.18, 0.985)), BorderColor::all(CYAN), )) .with_children(|help| { @@ -498,7 +496,7 @@ fn spawn_result_overlay(root: &mut ChildSpawnerCommands) { }, UiTransform::from_translation(Val2::percent(-50.0, -50.0)), GlobalZIndex(60), - BackgroundColor(Color::srgba(0.025, 0.039, 0.049, 0.985)), + BackgroundColor(Color::srgba(0.10, 0.04, 0.18, 0.985)), BorderColor::all(CYAN), Pickable::IGNORE, )) @@ -608,18 +606,7 @@ fn command_bar_copy( } }; match context { - HudContext::Idle => ( - format!( - "SELECTED CLUSTERS // {} CELL{}", - interaction.sources.len(), - plural(interaction.sources.len()) - ), - format!( - "SHARE {:>3}% · LMB neutral expand · LMB enemy attack{contextual_status} · ? manual", - interaction.amount_percent, - ), - "C cluster · Shift/Ctrl+C multi · Ctrl+A all · B rebalance fronts · [ / ] Share · T reshape · X stop".to_owned(), - ), + HudContext::Idle => idle_command_bar_copy(interaction, &contextual_status), HudContext::AttackTargets => ( "ATTACK CLUSTERS // TARGETS".to_owned(), format!( @@ -726,6 +713,59 @@ fn command_bar_copy( } } +fn idle_command_bar_copy( + interaction: &InteractionState, + contextual_status: &str, +) -> (String, String, String) { + let title = format!( + "SELECTED CLUSTERS // {} CELL{}", + interaction.sources.len(), + plural(interaction.sources.len()) + ); + let valid_hover = interaction.preview.invalid_reason.is_none(); + let summary = match interaction.preview.contextual_hover { + ContextualHover::Expand if valid_hover => { + let focus = interaction.preview.focus.unwrap_or(hex_core::Axial::ZERO); + format!( + "ALL PERIMETERS · FOCUS {:+},{:+} · SHARE {:>3}%{contextual_status}", + focus.q, focus.r, interaction.amount_percent + ) + } + ContextualHover::Attack if valid_hover => format!( + "TARGET MASK · {} HEX{} · SHARE {:>3}% · {} FRONT{}{contextual_status}", + interaction.preview.hover_targets.len(), + plural(interaction.preview.hover_targets.len()), + interaction.amount_percent, + interaction.preview.front_edges.len(), + plural(interaction.preview.front_edges.len()), + ), + _ if interaction.sources.is_empty() => { + format!("C cluster · click after selecting{contextual_status} · ? manual") + } + _ => format!( + "C cluster · click unclaimed expand · click enemy attack{contextual_status} · ? manual" + ), + }; + let mut hints = vec!["C cluster".to_owned()]; + if matches!( + interaction.preview.contextual_hover, + ContextualHover::Expand | ContextualHover::Attack + ) && valid_hover + { + hints.push("[ / ] Share".to_owned()); + } + if interaction.preview.show_rebalance_hint { + hints.push("B rebalance".to_owned()); + } + if interaction.preview.show_reshape_hint { + hints.push("T reshape".to_owned()); + } + if interaction.preview.show_stop_hint { + hints.push("X stop".to_owned()); + } + (title, summary, hints.join(" · ")) +} + #[allow(clippy::too_many_arguments)] fn update_hud( mut commands: Commands, @@ -952,7 +992,7 @@ fn update_hud( ToastKind::Success => Color::srgb(0.41, 0.86, 0.58), ToastKind::Rejection => CORAL, }; - toast_root.1.0 = Color::srgba(0.035, 0.052, 0.064, 0.98); + toast_root.1.0 = Color::srgba(0.12, 0.05, 0.22, 0.98); toast_root.2.set_all(accent); } let mut toast_text = texts.p5(); @@ -1024,7 +1064,7 @@ fn update_slider_visuals( ) { thumb.0.left = percent(slider.1.thumb_position(slider.0.0) * 100.0); thumb.1.0 = if slider.2.get() || slider.3.dragging { - Color::srgb(0.66, 0.98, 1.0) + Color::srgb(0.72, 1.0, 0.55) } else { CYAN }; @@ -1098,3 +1138,100 @@ fn set_text(text: &mut Text, value: String) { const fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "S" } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::interaction::InteractionState; + use hex_core::Axial; + use std::collections::BTreeSet; + + #[test] + fn idle_strip_is_cluster_and_click_without_the_full_grammar() { + let interaction = InteractionState::default(); + let (_, summary, hints) = command_bar_copy(&interaction, HudContext::Idle); + + assert_eq!(hints, "C cluster"); + assert!(summary.contains("C cluster")); + assert!(summary.contains("click")); + assert!( + !summary.contains("SHARE"), + "Share must wait for a valid hover: {summary}" + ); + assert!(!hints.contains('B')); + assert!(!hints.contains('T')); + assert!(!hints.contains('X')); + assert!(!hints.contains("Share")); + assert!(!hints.contains("Ctrl+A")); + } + + #[test] + fn idle_strip_shows_share_only_on_valid_expand_hover() { + let mut interaction = InteractionState::default(); + interaction.sources = BTreeSet::from([Axial::ZERO]); + interaction.amount_percent = 40; + interaction.preview.contextual_hover = ContextualHover::Expand; + interaction.preview.focus = Some(Axial::new(2, -1)); + let (_, summary, hints) = command_bar_copy(&interaction, HudContext::Idle); + + assert!(summary.contains("ALL PERIMETERS")); + assert!(summary.contains("FOCUS +2,-1")); + assert!(summary.contains("SHARE 40%")); + assert!(hints.contains("[ / ] Share")); + assert!(!hints.contains("T reshape")); + assert!(!hints.contains("X stop")); + assert!(!hints.contains("B rebalance")); + } + + #[test] + fn idle_strip_shows_attack_mask_and_share_on_enemy_hover() { + let mut interaction = InteractionState::default(); + interaction.sources = BTreeSet::from([Axial::ZERO]); + interaction.amount_percent = 30; + interaction.preview.contextual_hover = ContextualHover::Attack; + interaction.preview.hover_targets = BTreeSet::from([Axial::new(1, 0), Axial::new(2, 0)]); + interaction.preview.front_edges = vec![hex_core::DirectedFrontEdge { + source: Axial::ZERO, + target: Axial::new(1, 0), + }]; + let (_, summary, hints) = command_bar_copy(&interaction, HudContext::Idle); + + assert!(summary.contains("TARGET MASK")); + assert!(summary.contains("2 HEXS")); + assert!(summary.contains("SHARE 30%")); + assert!(summary.contains("1 FRONT")); + assert!(hints.contains("[ / ] Share")); + } + + #[test] + fn idle_strip_gates_t_x_and_b_on_live_context() { + let mut interaction = InteractionState::default(); + interaction.sources = BTreeSet::from([Axial::ZERO]); + interaction.preview.show_reshape_hint = true; + interaction.preview.show_stop_hint = true; + interaction.preview.show_rebalance_hint = true; + let (_, _, hints) = command_bar_copy(&interaction, HudContext::Idle); + + assert!(hints.contains("C cluster")); + assert!(hints.contains("T reshape")); + assert!(hints.contains("X stop")); + assert!(hints.contains("B rebalance")); + assert!( + !hints.contains("Share"), + "Share stays hover-gated even when other keys appear: {hints}" + ); + } + + #[test] + fn invalid_hover_does_not_advertise_share() { + let mut interaction = InteractionState::default(); + interaction.sources = BTreeSet::from([Axial::ZERO]); + interaction.preview.contextual_hover = ContextualHover::Expand; + interaction.preview.invalid_reason = + Some("Eligible perimeter cells have no visible infantry to request"); + let (_, summary, hints) = command_bar_copy(&interaction, HudContext::Idle); + + assert!(!hints.contains("Share")); + assert!(!summary.contains("ALL PERIMETERS")); + } +} diff --git a/crates/game-client/src/interaction.rs b/crates/game-client/src/interaction.rs index 83a0bdb..d16225c 100644 --- a/crates/game-client/src/interaction.rs +++ b/crates/game-client/src/interaction.rs @@ -2,8 +2,9 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use bevy::{picking::pointer::PointerInteraction, prelude::*}; use hex_core::{ - Axial, DirectedFrontEdge, FrontSelectionError, StrategicExterior, selected_directional_routes, - selected_front_edges, strategic_front_index_for_seed, strategic_fronts, + Axial, DirectedFrontEdge, FrontSelectionError, StrategicExterior, StrategicFront, + focus_branch_weight, selected_directional_routes, selected_front_edges, + strategic_front_index_for_seed, strategic_fronts, }; use crate::{ @@ -12,13 +13,14 @@ use crate::{ model::{MatchView, OrderSelectionProjectionError, ProjectedOrderSelection, ToastKind}, network::{ ClientIntent, ExpandWaveError, MAX_WAVE_PREVIEW_RINGS, NetworkSet, ServerUpdate, - arc_push_routes, forecast_attack_wave, forecast_expand_wave, projected_shape_distribution, - push_edge_is_eligible, resolve_projected_push_front, + arc_push_routes, forecast_attack_wave, forecast_expand_wave_toward, + projected_shape_distribution, push_edge_is_eligible, resolve_projected_push_front, }, terrain::TerrainChunk, }; #[derive(Clone, Debug, Default)] +#[allow(clippy::struct_excessive_bools)] pub struct OrderPreview { /// One aggregated reinforcement corridor for every independently /// traversable Push component. Rendering one representative route per @@ -54,6 +56,27 @@ pub struct OrderPreview { pub reshape_outside_strength: u64, pub component_bottlenecks: Vec<(Axial, Axial)>, pub invalid_reason: Option<&'static str>, + /// Idle look-ahead for the contextual click under the pointer. This is + /// presentation only: it does not enter a ready/preview mode or change + /// what the subsequent click dispatches. + pub contextual_hover: ContextualHover, + pub focus: Option, + pub branch_weights: BTreeMap<(Axial, Axial), u8>, + /// Selected cells that will contribute 0 to the hovered expand/attack. + pub inland: BTreeSet, + /// Complete enemy cluster under an idle attack hover. + pub hover_targets: BTreeSet, + pub show_reshape_hint: bool, + pub show_rebalance_hint: bool, + pub show_stop_hint: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ContextualHover { + #[default] + None, + Expand, + Attack, } #[derive(Clone, Debug)] @@ -273,6 +296,7 @@ struct OrderPreviewKey { state_revision: u64, topology_revision: u64, retask_revision: u64, + hover: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -480,6 +504,11 @@ impl InteractionState { !self.sources.is_empty() || !self.retask_handles.is_empty() } + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn invalidate_preview(&mut self) { + self.preview_key = None; + } + pub fn push_direction(&self) -> Option { match self.mode { OrderMode::PushFrontOrient { start, current } => { @@ -1816,13 +1845,18 @@ fn order_preview_key(view: &MatchView, interaction: &InteractionState) -> Option mode, shape_revision: interaction.shape_revision, attack_revision: interaction.attack_revision, - state_revision: if matches!(interaction.mode, OrderMode::Idle) { + state_revision: if matches!(interaction.mode, OrderMode::Idle) + && interaction.hovered.is_none() + { view.ownership_revision } else { view.planning_revision }, topology_revision: view.chunk_index_revision, retask_revision: view.retask_revision, + hover: matches!(interaction.mode, OrderMode::Idle) + .then_some(interaction.hovered) + .flatten(), }) } @@ -2134,13 +2168,14 @@ fn build_expand_all_preview( preview.invalid_reason = Some("Expand Perimeter sources are no longer available"); return; }; - build_projected_expand_all_preview(view, &projection, commitment_percent, preview); + build_projected_expand_all_preview(view, &projection, commitment_percent, None, preview); } fn build_projected_expand_all_preview( view: &MatchView, projection: &ProjectedOrderSelection, commitment_percent: u8, + focus: Option, preview: &mut OrderPreview, ) { let selected = &projection.cells; @@ -2170,11 +2205,12 @@ fn build_projected_expand_all_preview( return; } - let forecast = match forecast_expand_wave( + let forecast = match forecast_expand_wave_toward( view, &sources, &projection.affected_strength_by_cell, commitment_percent, + focus, MAX_WAVE_PREVIEW_RINGS, ) { Ok(forecast) => forecast, @@ -2195,6 +2231,7 @@ fn build_projected_expand_all_preview( preview.wave_depth = forecast.reached_depth; preview.wave_truncated = forecast.truncated; preview.strength_upper_bound = forecast.strength_upper_bound; + record_expand_branch_preview(preview, &projection.cells, focus); if preview.strength_upper_bound == 0 { preview.invalid_reason = Some("Eligible perimeter cells have no visible infantry to request"); @@ -2210,6 +2247,137 @@ fn build_projected_expand_all_preview( u32::try_from(u64::from(outside_depth).saturating_mul(2)).unwrap_or(u32::MAX); } +fn record_expand_branch_preview( + preview: &mut OrderPreview, + selected: &BTreeSet, + focus: Option, +) { + preview.focus = focus; + preview.branch_weights.clear(); + if let Some(focus) = focus { + for edge in &preview.front_edges { + preview.branch_weights.insert( + (edge.source, edge.target), + focus_branch_weight(edge.source, edge.target, focus), + ); + } + } + let participating = preview + .front_edges + .iter() + .map(|edge| edge.source) + .collect::>(); + preview.inland = selected.difference(&participating).copied().collect(); +} + +fn build_idle_contextual_preview( + view: &MatchView, + interaction: &InteractionState, + projection: &ProjectedOrderSelection, + preview: &mut OrderPreview, +) { + if interaction.sources.is_empty() { + return; + } + let Some(hovered) = interaction.hovered else { + return; + }; + let Some(cell) = view.cell(hovered) else { + return; + }; + if cell.owner.is_none() && view.is_capturable(hovered) { + build_projected_expand_all_preview( + view, + projection, + interaction.amount_percent, + Some(hovered), + preview, + ); + if preview.invalid_reason.is_none() { + preview.contextual_hover = ContextualHover::Expand; + } + return; + } + if cell.owner.is_none_or(|owner| owner == view.local_player) || !view.is_capturable(hovered) { + return; + } + let cluster = enemy_owned_cluster(view, hovered); + if cluster.is_empty() { + return; + } + build_attack_clusters_preview( + view, + projection, + &cluster, + interaction.amount_percent, + preview, + ); + preview.hover_targets.clone_from(&cluster); + if preview.invalid_reason.is_none() { + preview.contextual_hover = ContextualHover::Attack; + let participating = preview + .front_edges + .iter() + .map(|edge| edge.source) + .collect::>(); + preview.inland = projection + .cells + .difference(&participating) + .copied() + .collect(); + } +} + +fn fill_cluster_command_hints( + view: &MatchView, + interaction: &InteractionState, + preview: &mut OrderPreview, +) { + preview.show_stop_hint = !stop_order_ids(view, interaction).is_empty(); + preview.show_rebalance_hint = selected_complete_component(view, interaction) + .is_some_and(|component| front_rebalance_component_error(view, &component).is_ok()); + preview.show_reshape_hint = selected_owned_cluster_count(view, &interaction.sources) == 1 + && selected_cluster_has_inland_free_infantry(view, interaction); +} + +fn selected_cluster_has_inland_free_infantry( + view: &MatchView, + interaction: &InteractionState, +) -> bool { + let Some(component) = selected_complete_component(view, interaction) else { + return false; + }; + inland_cells_of_component(view, &component) + .iter() + .any(|coordinate| { + let infantry = view.cell(*coordinate).map_or(0, |cell| cell.infantry); + let active = view + .retask_projection + .active_strength_by_cell + .get(coordinate) + .copied() + .unwrap_or(0) + .min(infantry); + infantry > active + }) +} + +fn inland_cells_of_component(view: &MatchView, component: &BTreeSet) -> BTreeSet { + let Ok(fronts) = strategic_fronts(component.iter().copied(), |_, target| { + strategic_exterior_for_view(view, target) + }) else { + return BTreeSet::new(); + }; + if fronts.is_empty() { + return BTreeSet::new(); + } + let perimeter = fronts + .iter() + .flat_map(StrategicFront::source_cells) + .collect::>(); + component.difference(&perimeter).copied().collect() +} + fn selected_reachability_to_front( view: &MatchView, sources: &BTreeSet, @@ -2447,8 +2615,12 @@ fn rebuild_order_preview(view: &MatchView, interaction: &mut InteractionState) { view, &projection, interaction.amount_percent, + None, &mut preview, ), + OrderMode::Idle => { + build_idle_contextual_preview(view, interaction, &projection, &mut preview); + } OrderMode::ReshapeDrawing | OrderMode::ReshapePreview => { build_projected_shape_preview( view, @@ -2458,9 +2630,9 @@ fn rebuild_order_preview(view: &MatchView, interaction: &mut InteractionState) { ); } OrderMode::StopPreview { order_ids } => build_stop_preview(view, order_ids, &mut preview), - OrderMode::Idle => {} OrderMode::Submitting { .. } => unreachable!("submitting previews return before rebuild"), } + fill_cluster_command_hints(view, interaction, &mut preview); interaction.preview = preview; interaction.preview_key = Some(key); } @@ -4622,6 +4794,209 @@ mod tests { assert!(preview.excluded.is_empty()); } + #[test] + fn idle_neutral_hover_previews_perimeter_weights_share_and_inland() { + let inland = Axial::ZERO; + let selected = hex_disk(1).into_iter().collect::>(); + let focus = Axial::new(2, 0); + let mut view = MatchView::connecting(1); + for coordinate in hex_disk(2) { + let owner = selected.contains(&coordinate).then_some(1); + let infantry = if coordinate == inland { 80 } else { 20 }; + view.cells + .insert(coordinate, preview_cell(coordinate, owner, infantry, 0)); + } + view.rebuild_chunk_index(); + + let mut interaction = InteractionState { + hovered: Some(focus), + sources: selected.clone(), + amount_percent: 50, + ..Default::default() + }; + rebuild_order_preview(&view, &mut interaction); + + assert_eq!( + interaction.preview.contextual_hover, + ContextualHover::Expand + ); + assert_eq!(interaction.preview.focus, Some(focus)); + assert_eq!(interaction.preview.invalid_reason, None); + assert!(interaction.preview.inland.contains(&inland)); + assert!( + !interaction + .preview + .front_edges + .iter() + .any(|edge| edge.source == inland) + ); + let participating = interaction + .preview + .front_edges + .iter() + .map(|edge| edge.source) + .collect::>(); + assert!(!participating.is_empty()); + assert!(selected.is_superset(&participating)); + let weights = interaction + .preview + .branch_weights + .values() + .copied() + .collect::>(); + assert_eq!(weights, BTreeSet::from([9, 10, 11])); + assert!( + interaction + .preview + .branch_weights + .iter() + .any(|(&(source, target), &weight)| { + weight == 11 && focus_branch_weight(source, target, focus) == 11 + }) + ); + assert_eq!( + interaction.preview.strength_upper_bound, + participating + .iter() + .map(|coordinate| { + view.cell(*coordinate).map_or(0, |cell| cell.infantry) * 50 / 100 + }) + .sum::() + ); + assert!( + interaction.preview.show_reshape_hint, + "a complete cluster with inland free infantry should advertise Reshape" + ); + } + + #[test] + fn idle_enemy_hover_previews_the_full_mask_and_firing_fronts() { + let source = Axial::ZERO; + let enemy_a = Axial::new(1, 0); + let enemy_b = Axial::new(2, 0); + let inland = Axial::new(-1, 0); + let mut view = MatchView::connecting(1); + for cell in [ + preview_cell(inland, Some(1), 40, 0), + preview_cell(source, Some(1), 40, 0), + preview_cell(enemy_a, Some(2), 20, 0), + preview_cell(enemy_b, Some(2), 20, 0), + ] { + view.cells.insert(cell.coordinate, cell); + } + view.rebuild_chunk_index(); + + let mut interaction = InteractionState { + hovered: Some(enemy_a), + sources: BTreeSet::from([inland, source]), + amount_percent: 50, + ..Default::default() + }; + rebuild_order_preview(&view, &mut interaction); + + assert_eq!( + interaction.preview.contextual_hover, + ContextualHover::Attack + ); + assert_eq!( + interaction.preview.hover_targets, + BTreeSet::from([enemy_a, enemy_b]) + ); + assert_eq!( + interaction.preview.front_edges, + vec![DirectedFrontEdge { + source, + target: enemy_a + }] + ); + assert!(interaction.preview.inland.contains(&inland)); + assert!(!interaction.preview.inland.contains(&source)); + assert_eq!(interaction.preview.strength_upper_bound, 20); + assert!(interaction.preview.branch_weights.is_empty()); + } + + #[test] + fn idle_hover_on_owned_ground_does_not_invent_a_contextual_preview() { + let source = Axial::ZERO; + let mut view = MatchView::connecting(1); + view.cells + .insert(source, preview_cell(source, Some(1), 40, 0)); + view.rebuild_chunk_index(); + let mut interaction = InteractionState { + hovered: Some(source), + sources: BTreeSet::from([source]), + ..Default::default() + }; + rebuild_order_preview(&view, &mut interaction); + + assert_eq!(interaction.preview.contextual_hover, ContextualHover::None); + assert!(interaction.preview.front_edges.is_empty()); + assert!(interaction.preview.hover_targets.is_empty()); + assert!(!interaction.preview.show_reshape_hint); + } + + #[test] + fn idle_hints_show_stop_only_with_live_orders_and_b_only_with_two_fronts() { + let inland = Axial::ZERO; + let west = Axial::new(-1, 0); + let east = Axial::new(1, 0); + let enemy = Axial::new(2, 0); + let open_west = Axial::new(-2, 0); + let mut view = MatchView::connecting(1); + for cell in [ + preview_cell(inland, Some(1), 50, 0), + preview_cell(west, Some(1), 20, 0), + preview_cell(east, Some(1), 20, 0), + preview_cell(enemy, Some(2), 20, 0), + preview_cell(open_west, None, 0, 0), + ] { + view.cells.insert(cell.coordinate, cell); + } + view.rebuild_chunk_index(); + let sources = BTreeSet::from([inland, west, east]); + let mut interaction = InteractionState { + sources: sources.clone(), + ..Default::default() + }; + rebuild_order_preview(&view, &mut interaction); + assert!( + interaction.preview.show_rebalance_hint, + "neutral west + hostile east should be two strategic fronts" + ); + assert!(interaction.preview.show_reshape_hint); + assert!(!interaction.preview.show_stop_hint); + + view.set_retask_projection(RetaskProjection { + handle_orders: BTreeMap::new(), + active_order_ids: BTreeSet::from([9]), + order_source_cells: BTreeMap::from([(9, BTreeSet::from([east]))]), + order_strength_by_cell: BTreeMap::from([(9, BTreeMap::from([(east, 10)]))]), + active_strength_by_cell: BTreeMap::from([(east, 10)]), + destination_reservations_by_order: BTreeMap::new(), + destination_claims_by_order: BTreeMap::new(), + }); + interaction.preview_key = None; + rebuild_order_preview(&view, &mut interaction); + assert!(interaction.preview.show_stop_hint); + + let mut single = InteractionState { + sources: BTreeSet::from([Axial::new(4, 0)]), + ..Default::default() + }; + view.cells.insert( + Axial::new(4, 0), + preview_cell(Axial::new(4, 0), Some(1), 30, 0), + ); + view.cells + .insert(Axial::new(5, 0), preview_cell(Axial::new(5, 0), None, 0, 0)); + view.rebuild_chunk_index(); + rebuild_order_preview(&view, &mut single); + assert!( + !single.preview.show_reshape_hint, + "a one-cell cluster has no inland reserve" + ); + } + #[test] fn map_click_waits_when_share_changes_after_the_displayed_preview() { let source = Axial::ZERO; diff --git a/crates/game-client/src/lobby.rs b/crates/game-client/src/lobby.rs index d4a4edd..fa7c0a8 100644 --- a/crates/game-client/src/lobby.rs +++ b/crates/game-client/src/lobby.rs @@ -9,15 +9,15 @@ use crate::{ model::{MatchPhase, MatchView}, }; -const SCRIM: Color = Color::srgba(0.008, 0.014, 0.018, 0.88); -const CARD: Color = Color::srgba(0.035, 0.052, 0.064, 0.985); -const FIELD: Color = Color::srgb(0.055, 0.078, 0.092); -const LINE: Color = Color::srgba(0.42, 0.58, 0.65, 0.55); -const TEXT: Color = Color::srgb(0.88, 0.93, 0.95); -const MUTED: Color = Color::srgb(0.57, 0.68, 0.72); -const CYAN: Color = Color::srgb(0.40, 0.87, 0.91); -const ACTIVE: Color = Color::srgb(0.10, 0.31, 0.35); -const DISABLED: Color = Color::srgb(0.075, 0.085, 0.09); +const SCRIM: Color = Color::srgba(0.10, 0.04, 0.18, 0.88); +const CARD: Color = Color::srgba(0.14, 0.05, 0.24, 0.985); +const FIELD: Color = Color::srgb(0.22, 0.08, 0.32); +const LINE: Color = Color::srgba(1.0, 0.42, 0.78, 0.58); +const TEXT: Color = Color::srgb(1.0, 0.97, 0.92); +const MUTED: Color = Color::srgb(0.92, 0.78, 0.98); +const CYAN: Color = Color::srgb(0.22, 0.98, 0.92); +const ACTIVE: Color = Color::srgb(0.22, 0.12, 0.42); +const DISABLED: Color = Color::srgb(0.12, 0.08, 0.14); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum LobbyMapPreset { diff --git a/crates/game-client/src/main.rs b/crates/game-client/src/main.rs index 4dada8d..ddde0b7 100644 --- a/crates/game-client/src/main.rs +++ b/crates/game-client/src/main.rs @@ -24,6 +24,8 @@ mod online; mod overlays; mod performance; mod population_outline; +#[cfg(not(target_arch = "wasm32"))] +mod screenshot; mod terrain; use bevy::{prelude::*, window::WindowResolution}; @@ -107,10 +109,10 @@ fn main() { console_enabled: config.observe, }, )) - .insert_resource(ClearColor(Color::srgb(0.018, 0.025, 0.031))) + .insert_resource(ClearColor(Color::srgb(0.28, 0.62, 0.86))) .insert_resource(GlobalAmbientLight { - color: Color::srgb(0.47, 0.56, 0.66), - brightness: 340.0, + color: Color::srgb(0.92, 0.78, 1.0), + brightness: 220.0, ..default() }) .add_systems(Startup, (spawn_camera_and_light, spawn_terrain).chain()) @@ -130,5 +132,9 @@ fn main() { } else { app.add_plugins(OnlineTransportPlugin); } + #[cfg(not(target_arch = "wasm32"))] + if config.screenshot_path.is_some() { + app.add_plugins(screenshot::OfflineScreenshotPlugin); + } app.run(); } diff --git a/crates/game-client/src/network.rs b/crates/game-client/src/network.rs index 2fc10af..aed0c2d 100644 --- a/crates/game-client/src/network.rs +++ b/crates/game-client/src/network.rs @@ -1061,6 +1061,7 @@ fn distribute_wave_strength( } } +#[allow(dead_code)] pub(crate) fn forecast_expand_wave( view: &MatchView, sources: &BTreeSet, diff --git a/crates/game-client/src/overlays.rs b/crates/game-client/src/overlays.rs index 0483009..843fbb3 100644 --- a/crates/game-client/src/overlays.rs +++ b/crates/game-client/src/overlays.rs @@ -9,24 +9,29 @@ use hex_core::{Axial, ChunkCoord}; use crate::{ camera::GameCamera, geometry::{chunk_of, corner, edge_index_for_direction, world_center}, - interaction::{InteractionState, OrderMode}, + interaction::{ContextualHover, InteractionState, OrderMode}, model::{CellView, MatchView}, terrain::TerrainChunk, }; -const SOURCE: Color = Color::srgb(0.44, 0.90, 0.94); -const FRIENDLY: Color = Color::srgb(0.26, 0.78, 0.91); -const HOSTILE: Color = Color::srgb(1.0, 0.39, 0.30); -const AMBER: Color = Color::srgb(1.0, 0.69, 0.25); -const RETASK: Color = Color::srgb(0.91, 0.54, 1.0); -const BLOCKED: Color = Color::srgba(0.83, 0.35, 0.24, 0.72); -const BRUSH_VALID: Color = Color::srgba(0.96, 0.98, 1.0, 0.92); -const BRUSH_SELECTED: Color = Color::srgba(0.44, 0.90, 0.94, 0.92); -const BRUSH_RETASK: Color = Color::srgba(0.91, 0.54, 1.0, 0.92); -const BRUSH_BLOCKED: Color = Color::srgba(1.0, 0.69, 0.25, 0.88); -const BRUSH_FOREIGN: Color = Color::srgba(1.0, 0.34, 0.24, 0.88); -const BRUSH_OFF_MAP: Color = Color::srgba(0.48, 0.57, 0.61, 0.78); -const SHAPE_TARGET: Color = Color::srgb(0.54, 0.94, 0.56); +const SOURCE: Color = Color::srgb(0.18, 0.98, 0.92); +const FRIENDLY: Color = Color::srgb(0.32, 0.94, 1.0); +const HOSTILE: Color = Color::srgb(1.0, 0.28, 0.46); +const AMBER: Color = Color::srgb(1.0, 0.82, 0.18); +const RETASK: Color = Color::srgb(0.94, 0.42, 1.0); +const BLOCKED: Color = Color::srgba(1.0, 0.32, 0.28, 0.82); +const BRUSH_VALID: Color = Color::srgba(1.0, 0.98, 0.55, 0.94); +const BRUSH_SELECTED: Color = Color::srgba(0.18, 0.98, 0.92, 0.94); +const BRUSH_RETASK: Color = Color::srgba(0.94, 0.42, 1.0, 0.94); +const BRUSH_BLOCKED: Color = Color::srgba(1.0, 0.78, 0.18, 0.90); +const BRUSH_FOREIGN: Color = Color::srgba(1.0, 0.32, 0.42, 0.90); +const BRUSH_OFF_MAP: Color = Color::srgba(0.62, 0.48, 0.92, 0.78); +const SHAPE_TARGET: Color = Color::srgb(0.42, 1.0, 0.48); +const INLAND: Color = Color::srgba(0.18, 0.12, 0.28, 0.62); +const HOVER: Color = Color::srgb(1.0, 1.0, 1.0); +const WEIGHT_TOWARD: Color = Color::srgb(1.0, 0.96, 0.22); +const WEIGHT_EQUAL: Color = Color::srgb(1.0, 0.72, 0.18); +const WEIGHT_AWAY: Color = Color::srgb(0.92, 0.48, 0.16); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum BrushCategory { @@ -73,6 +78,7 @@ fn draw_world_overlays( mut gizmos: Gizmos, ) { let needs_visible_cells = interaction.has_selection() + || interaction.hovered.is_some() || !interaction.attack_targets.is_empty() || !interaction.preview.front_edges.is_empty() || !interaction.preview.wave_depth.is_empty() @@ -80,7 +86,10 @@ fn draw_world_overlays( || !interaction.preview.delta_by_cell.is_empty() || !interaction.preview.component_routes.is_empty() || !interaction.preview.projected_sources.is_empty() - || !interaction.preview.excluded.is_empty(); + || !interaction.preview.excluded.is_empty() + || !interaction.preview.inland.is_empty() + || !interaction.preview.hover_targets.is_empty() + || interaction.preview.contextual_hover != ContextualHover::None; scratch.cells.clear(); if needs_visible_cells { append_visible_cell_coordinates(&visible, &chunks, &mut scratch.cells); @@ -106,6 +115,7 @@ fn draw_world_overlays( } draw_blocked_cells(&view, &visible, &chunks, &mut gizmos); draw_selection(&view, &interaction, &scratch.cells, &mut gizmos); + draw_hover_cell(&view, &interaction, &mut gizmos); draw_preview( &view, &interaction, @@ -390,6 +400,183 @@ fn perimeter_edge_is_exposed(selection: &BTreeSet, neighbor: Axial) -> bo !selection.contains(&neighbor) } +fn draw_hover_cell(view: &MatchView, interaction: &InteractionState, gizmos: &mut Gizmos) { + let Some(hovered) = interaction.hovered else { + return; + }; + let Some(cell) = view.cell(hovered) else { + return; + }; + draw_hex(gizmos, point(cell, 0.18), 1.04, HOVER); +} + +fn draw_inland_cells( + view: &MatchView, + interaction: &InteractionState, + visible_cells: &[Axial], + gizmos: &mut Gizmos, +) { + if interaction.preview.inland.is_empty() { + return; + } + for coordinate in visible_cells { + if !interaction.preview.inland.contains(coordinate) { + continue; + } + let Some(cell) = view.cell(*coordinate) else { + continue; + }; + draw_hex(gizmos, point(cell, 0.08), 0.72, INLAND); + draw_segmented_hex(gizmos, point(cell, 0.10), 0.88, INLAND, 0.28); + } +} + +fn draw_participating_perimeter( + view: &MatchView, + interaction: &InteractionState, + visible_cells: &[Axial], + gizmos: &mut Gizmos, +) { + if !matches!( + interaction.preview.contextual_hover, + ContextualHover::Expand | ContextualHover::Attack + ) { + return; + } + let participating = interaction + .preview + .front_edges + .iter() + .map(|edge| edge.source) + .collect::>(); + if participating.is_empty() { + return; + } + draw_region_perimeter( + view, + &participating, + visible_cells, + PerimeterStyle { + lift: 0.20, + scale: 0.78, + }, + |_| SOURCE, + gizmos, + ); + for coordinate in visible_cells + .iter() + .filter(|coordinate| participating.contains(coordinate)) + { + let Some(cell) = view.cell(*coordinate) else { + continue; + }; + draw_hex(gizmos, point(cell, 0.16), 0.58, SOURCE); + } +} + +fn draw_focus_marker(view: &MatchView, interaction: &InteractionState, gizmos: &mut Gizmos) { + let Some(focus) = interaction.preview.focus else { + return; + }; + let Some(cell) = view.cell(focus) else { + return; + }; + let center = point(cell, 0.22); + draw_hex(gizmos, center, 1.08, AMBER); + gizmos + .sphere(center + Vec3::Y * 0.08, 0.12, AMBER) + .resolution(5); +} + +fn draw_branch_weight_labels( + view: &MatchView, + interaction: &InteractionState, + visible_chunks: &BTreeSet, + gizmos: &mut Gizmos, +) { + for edge in &interaction.preview.front_edges { + let Some(&weight) = interaction + .preview + .branch_weights + .get(&(edge.source, edge.target)) + else { + continue; + }; + if !edge_touches_visible_chunk(visible_chunks, edge.source, edge.target) { + continue; + } + let (Some(source), Some(target)) = (view.cell(edge.source), view.cell(edge.target)) else { + continue; + }; + let label_at = point(source, 0.28).lerp(point(target, 0.28), 0.58); + draw_weight_digits(gizmos, label_at, weight, branch_weight_color(weight)); + } +} + +fn branch_weight_color(weight: u8) -> Color { + match weight { + 11 => WEIGHT_TOWARD, + 10 => WEIGHT_EQUAL, + _ => WEIGHT_AWAY, + } +} + +/// Tiny 7-segment digits so 11/10/9 sit on the branch without a font atlas. +fn draw_weight_digits(gizmos: &mut Gizmos, origin: Vec3, weight: u8, color: Color) { + let glyphs = match weight { + 11 => [Some(1), Some(1)], + 10 => [Some(1), Some(0)], + 9 => [None, Some(9)], + _ => return, + }; + let width = 0.09; + let gap = 0.04; + let start_x = -((glyphs.iter().flatten().count() as f32 - 1.0) * (width + gap) * 0.5); + for (index, glyph) in glyphs.into_iter().flatten().enumerate() { + let offset = Vec3::new(start_x + index as f32 * (width + gap), 0.0, 0.0); + draw_seven_segment(gizmos, origin + offset, width, 0.16, glyph, color); + } +} + +fn draw_seven_segment( + gizmos: &mut Gizmos, + origin: Vec3, + width: f32, + height: f32, + digit: u8, + color: Color, +) { + let half_w = width * 0.5; + let half_h = height * 0.5; + let top_left = origin + Vec3::new(-half_w, 0.0, -half_h); + let top_right = origin + Vec3::new(half_w, 0.0, -half_h); + let mid_left = origin + Vec3::new(-half_w, 0.0, 0.0); + let mid_right = origin + Vec3::new(half_w, 0.0, 0.0); + let bottom_left = origin + Vec3::new(-half_w, 0.0, half_h); + let bottom_right = origin + Vec3::new(half_w, 0.0, half_h); + let segments = match digit { + 0 => [true, true, true, true, true, true, false], + 1 => [false, true, true, false, false, false, false], + 9 => [true, true, true, true, false, true, true], + _ => return, + }; + // A, B, C, D, E, F, G + let lines = [ + (top_left, top_right), + (top_right, mid_right), + (mid_right, bottom_right), + (bottom_left, bottom_right), + (bottom_left, mid_left), + (top_left, mid_left), + (mid_left, mid_right), + ]; + for (on, (start, end)) in segments.into_iter().zip(lines) { + if on { + gizmos.line(start, end, color); + } + } +} + fn draw_preview( view: &MatchView, interaction: &InteractionState, @@ -397,9 +584,32 @@ fn draw_preview( visible_chunks: &BTreeSet, gizmos: &mut Gizmos, ) { + draw_inland_cells(view, interaction, visible_cells, gizmos); + draw_participating_perimeter(view, interaction, visible_cells, gizmos); draw_push_front_edges(view, interaction, visible_chunks, gizmos); - if matches!(interaction.mode, OrderMode::ExpandAllPreview) { + if matches!(interaction.mode, OrderMode::ExpandAllPreview) + || matches!( + interaction.preview.contextual_hover, + ContextualHover::Expand + ) + { draw_expand_wave(view, interaction, visible_cells, gizmos); + draw_focus_marker(view, interaction, gizmos); + draw_branch_weight_labels(view, interaction, visible_chunks, gizmos); + } + + if !interaction.preview.hover_targets.is_empty() { + draw_region_perimeter( + view, + &interaction.preview.hover_targets, + visible_cells, + PerimeterStyle { + lift: 0.17, + scale: 0.90, + }, + |_| HOSTILE, + gizmos, + ); } if !interaction.preview.heatmap.is_empty() { @@ -567,17 +777,41 @@ fn draw_push_front_edges( else { continue; }; - let color = push_front_edge_color(view.local_player, target.owner); + let color = interaction + .preview + .branch_weights + .get(&(edge.source, edge.target)) + .copied() + .map_or_else( + || push_front_edge_color(view.local_player, target.owner), + branch_weight_color, + ); let source_point = point(source, 0.155); let target_point = point(target, 0.155); draw_hex_edge(gizmos, source_point, direction, 1.025, color); - gizmos - .arrow( - source_point.lerp(target_point, 0.24), - source_point.lerp(target_point, 0.76), - color, - ) - .with_tip_length(0.17); + let lanes: u32 = interaction + .preview + .branch_weights + .get(&(edge.source, edge.target)) + .copied() + .map_or(1, |weight| match weight { + 11 => 3, + 10 => 2, + _ => 1, + }); + let along = target_point - source_point; + let side = Vec3::new(-along.z, 0.0, along.x).normalize_or_zero() * 0.05; + let start = lanes.saturating_sub(1) as f32 * -0.5; + for lane in 0..lanes { + let offset = side * (start + lane as f32); + gizmos + .arrow( + source_point.lerp(target_point, 0.24) + offset, + source_point.lerp(target_point, 0.76) + offset, + color, + ) + .with_tip_length(0.17); + } } } @@ -896,6 +1130,15 @@ mod tests { assert_eq!(push_front_edge_color(1, Some(2)), HOSTILE); } + #[test] + fn branch_weight_colors_keep_11_10_9_distinct() { + assert_eq!(branch_weight_color(11), WEIGHT_TOWARD); + assert_eq!(branch_weight_color(10), WEIGHT_EQUAL); + assert_eq!(branch_weight_color(9), WEIGHT_AWAY); + assert_ne!(branch_weight_color(11), branch_weight_color(10)); + assert_ne!(branch_weight_color(10), branch_weight_color(9)); + } + #[test] fn redistribution_delta_glyphs_scale_by_magnitude_not_sign() { assert!( diff --git a/crates/game-client/src/screenshot.rs b/crates/game-client/src/screenshot.rs new file mode 100644 index 0000000..5be76d8 --- /dev/null +++ b/crates/game-client/src/screenshot.rs @@ -0,0 +1,157 @@ +use std::{collections::BTreeSet, path::PathBuf}; + +use bevy::{ + prelude::*, + render::view::screenshot::{Screenshot, save_to_disk}, +}; +use hex_core::Axial; + +use crate::{ + config::{ClientConfig, ScreenshotScene}, + interaction::InteractionState, + model::{MatchView, PLAYER_ONE, PLAYER_TWO}, +}; + +const WARMUP: u32 = 40; +const SETTLE: u32 = 18; + +#[derive(Resource, Debug)] +struct CapturePlan { + path: PathBuf, + scene: ScreenshotScene, + frames: u32, + staged: bool, + requested: bool, +} + +pub struct OfflineScreenshotPlugin; + +impl Plugin for OfflineScreenshotPlugin { + fn build(&self, app: &mut App) { + let config = app.world().resource::(); + let Some(path) = config.screenshot_path.clone() else { + return; + }; + app.insert_resource(CapturePlan { + path, + scene: config.screenshot_scene, + frames: 0, + staged: false, + requested: false, + }) + .add_systems(Update, (stage_and_capture, drain_exit)); + } +} + +fn stage_and_capture( + mut commands: Commands, + mut plan: ResMut, + mut interaction: ResMut, + mut view: ResMut, +) { + plan.frames = plan.frames.saturating_add(1); + if !plan.staged && plan.frames >= WARMUP { + stage_scene(&mut view, &mut interaction, plan.scene); + plan.staged = true; + } + if plan.staged && !plan.requested && plan.frames >= WARMUP + SETTLE { + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(plan.path.clone())); + plan.requested = true; + commands.spawn(DelayedExit::default()); + } +} + +#[derive(Component, Default)] +struct DelayedExit { + frames: u32, +} + +fn drain_exit( + mut commands: Commands, + mut pending: Query<(Entity, &mut DelayedExit)>, + mut exit: MessageWriter, +) { + for (entity, mut delay) in &mut pending { + delay.frames = delay.frames.saturating_add(1); + if delay.frames >= 24 { + commands.entity(entity).despawn(); + exit.write(AppExit::Success); + } + } +} + +fn stage_scene(view: &mut MatchView, interaction: &mut InteractionState, scene: ScreenshotScene) { + match scene { + ScreenshotScene::Idle => { + interaction.hovered = None; + interaction.sources.clear(); + interaction.source_revision = interaction.source_revision.wrapping_add(1); + interaction.invalidate_preview(); + } + ScreenshotScene::ExpandHover => { + let seed = Axial::new(-8, 0); + let cluster = owned_cluster(view, seed); + let focus = cluster + .iter() + .flat_map(|coordinate| coordinate.neighbors()) + .find(|neighbor| { + view.cell(*neighbor) + .is_some_and(|cell| cell.owner.is_none() && view.is_capturable(*neighbor)) + }) + .unwrap_or(Axial::new(-4, 0)); + interaction.sources = cluster; + interaction.hovered = Some(focus); + interaction.last_map_hovered = Some(focus); + interaction.source_revision = interaction.source_revision.wrapping_add(1); + interaction.invalidate_preview(); + } + ScreenshotScene::AttackHover => { + let contact = Axial::new(5, 0); + let enemy = Axial::new(6, 0); + if let Some(cell) = view.cells.get_mut(&contact) { + cell.owner = Some(PLAYER_ONE); + cell.infantry = cell.military_capacity.min(40); + } + if let Some(cell) = view.cells.get_mut(&enemy) { + cell.owner = Some(PLAYER_TWO); + } + view.ownership_revision = view.ownership_revision.wrapping_add(1); + view.planning_revision = view.planning_revision.wrapping_add(1); + view.cell_state_revision = view.cell_state_revision.wrapping_add(1); + interaction.sources = BTreeSet::from([contact]); + interaction.hovered = Some(enemy); + interaction.last_map_hovered = Some(enemy); + interaction.source_revision = interaction.source_revision.wrapping_add(1); + interaction.invalidate_preview(); + } + } +} + +fn owned_cluster(view: &MatchView, seed: Axial) -> BTreeSet { + if !view.is_local_owned_passable(seed) { + return view + .cells + .values() + .find(|cell| { + cell.owner == Some(PLAYER_ONE) && view.is_local_owned_passable(cell.coordinate) + }) + .map(|cell| flood(view, cell.coordinate)) + .unwrap_or_default(); + } + flood(view, seed) +} + +fn flood(view: &MatchView, seed: Axial) -> BTreeSet { + let mut cluster = BTreeSet::from([seed]); + let mut frontier = vec![seed]; + while let Some(coordinate) = frontier.pop() { + for neighbor in coordinate.neighbors() { + if view.is_local_traversable_edge(coordinate, neighbor) && cluster.insert(neighbor) { + frontier.push(neighbor); + } + } + } + cluster +} diff --git a/crates/game-client/src/terrain.rs b/crates/game-client/src/terrain.rs index d2cedef..3823f15 100644 --- a/crates/game-client/src/terrain.rs +++ b/crates/game-client/src/terrain.rs @@ -258,8 +258,9 @@ pub fn spawn_terrain( ) { let material = materials.add(StandardMaterial { base_color: Color::WHITE, - perceptual_roughness: 0.96, + perceptual_roughness: 0.18, metallic: 0.0, + reflectance: 0.72, ..default() }); commands.insert_resource(TerrainMaterial(material.clone())); @@ -514,12 +515,12 @@ fn push_cell(builder: &mut ChunkMeshBuilder, view: &MatchView, cell: &CellView, ) .normalize(); let depth = ((top_y - bottom_y) / 2.4).clamp(0.0, 1.0); - let side_shade = 0.52 - depth * 0.12; + let side_shade = 0.78 - depth * 0.10; let side_color = shade(top_color, side_shade); let a = builder.vertex(cell.coordinate, bottom_a, normal, side_color, side_shade); let b = builder.vertex(cell.coordinate, bottom_b, normal, side_color, side_shade); - let c = builder.vertex(cell.coordinate, top_b, normal, shade(top_color, 0.68), 0.68); - let d = builder.vertex(cell.coordinate, top_a, normal, shade(top_color, 0.68), 0.68); + let c = builder.vertex(cell.coordinate, top_b, normal, shade(top_color, 0.92), 0.92); + let d = builder.vertex(cell.coordinate, top_a, normal, shade(top_color, 0.92), 0.92); builder.triangle(cell.coordinate, a, c, b); builder.triangle(cell.coordinate, a, d, c); } @@ -559,9 +560,9 @@ fn recolor_chunk_mesh( fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapViewMode) -> [f32; 4] { let base = if cell.is_water() { if cell.lake { - Color::srgb(0.075, 0.27, 0.31) + Color::srgb(0.18, 0.78, 0.92) } else { - Color::srgb(0.055, 0.16, 0.21) + Color::srgb(0.08, 0.46, 0.86) } } else { match cell.owner { @@ -584,12 +585,12 @@ fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapVie if cell.river && cell.is_land() { linear = mix_linear_rgba( linear, - LinearRgba::from(Color::srgb(0.055, 0.34, 0.43)), - 0.46, + LinearRgba::from(Color::srgb(0.12, 0.72, 0.95)), + 0.38, ); } let intensity = match mode { - MapViewMode::Overview => 0.42, + MapViewMode::Overview => 0.35, MapViewMode::Soldiers => normalized_soldier_strength( cell.infantry .saturating_add(contest.map_or(0, |contest| contest.attacker_strength)), @@ -598,11 +599,11 @@ fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapVie }; let terrain_light = match cell.terrain { TerrainKind::Plains => 1.0, - TerrainKind::Hills => 0.92, - TerrainKind::Mountain => 0.80, - TerrainKind::Water => 0.82, + TerrainKind::Hills => 0.94, + TerrainKind::Mountain => 0.88, + TerrainKind::Water => 0.90, }; - let ownership_readability = 0.58 + intensity * 0.68; + let ownership_readability = 0.78 + intensity * 0.20; linear.red = (linear.red * ownership_readability * terrain_light + intensity * 0.035).min(1.0); linear.green = (linear.green * ownership_readability * terrain_light + intensity * 0.045).min(1.0); @@ -611,14 +612,14 @@ fn cell_color(cell: &CellView, contest: Option<&ContestedCellView>, mode: MapVie } const PLAYER_PALETTE: [(f32, f32, f32); 8] = [ - (0.06, 0.48, 0.58), - (0.76, 0.24, 0.16), - (0.50, 0.32, 0.78), - (0.75, 0.62, 0.12), - (0.20, 0.62, 0.30), - (0.86, 0.34, 0.62), - (0.25, 0.43, 0.86), - (0.72, 0.43, 0.18), + (0.12, 0.82, 0.94), + (1.00, 0.28, 0.42), + (0.72, 0.38, 1.00), + (1.00, 0.86, 0.16), + (0.28, 0.94, 0.42), + (1.00, 0.42, 0.78), + (0.28, 0.52, 1.00), + (1.00, 0.55, 0.16), ]; fn player_color(player: u32) -> Option { @@ -632,8 +633,8 @@ fn player_color(player: u32) -> Option { // Golden-ratio hue walk keeps neighbors visually distinct without a table. let index = player - 1; let hue = ((index as f32) * 0.618_034).fract(); - let saturation = 0.55 + 0.25 * (((index * 3) % 5) as f32 / 4.0); - let lightness = 0.42 + 0.16 * (((index * 5) % 4) as f32 / 3.0); + let saturation = 0.72 + 0.18 * (((index * 3) % 5) as f32 / 4.0); + let lightness = 0.52 + 0.12 * (((index * 5) % 4) as f32 / 3.0); Some(hsl_color(hue, saturation, lightness)) } @@ -655,10 +656,10 @@ fn hsl_color(hue: f32, saturation: f32, lightness: f32) -> Color { fn terrain_color(terrain: TerrainKind) -> Color { match terrain { - TerrainKind::Plains => Color::srgb(0.39, 0.43, 0.30), - TerrainKind::Hills => Color::srgb(0.42, 0.38, 0.25), - TerrainKind::Mountain => Color::srgb(0.36, 0.35, 0.31), - TerrainKind::Water => Color::srgb(0.055, 0.16, 0.21), + TerrainKind::Plains => Color::srgb(0.55, 0.92, 0.32), + TerrainKind::Hills => Color::srgb(0.98, 0.72, 0.22), + TerrainKind::Mountain => Color::srgb(0.78, 0.62, 0.94), + TerrainKind::Water => Color::srgb(0.08, 0.46, 0.86), } } @@ -751,6 +752,29 @@ mod tests { } } + #[test] + fn player_and_terrain_colors_stay_saturated_for_the_plastic_look() { + let chroma = |color: Color| { + let [r, g, b, _] = LinearRgba::from(color).to_f32_array(); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + max - min + }; + for player in 1..=8 { + assert!( + chroma(player_color(player).expect("palette")) > 0.25, + "player {player} is too gray for the plastic pass" + ); + } + assert!(chroma(terrain_color(TerrainKind::Plains)) > 0.20); + assert!(chroma(terrain_color(TerrainKind::Hills)) > 0.20); + let overview = cell_color(&test_cell(0, 0, 100), None, MapViewMode::Overview); + assert!( + overview[..3].iter().sum::() > 0.9, + "overview hexes should stay bright: {overview:?}" + ); + } + #[test] fn every_supported_player_has_a_distinct_color() { let colors = (1..=8) @@ -764,8 +788,8 @@ mod tests { assert!(player_color(500).is_some()); assert!(player_color(501).is_none()); // Curated first-eight palette stays pinned. - assert_eq!(player_color(1), Some(Color::srgb(0.06, 0.48, 0.58))); - assert_eq!(player_color(2), Some(Color::srgb(0.76, 0.24, 0.16))); + assert_eq!(player_color(1), Some(Color::srgb(0.12, 0.82, 0.94))); + assert_eq!(player_color(2), Some(Color::srgb(1.00, 0.28, 0.42))); } #[test] @@ -919,15 +943,26 @@ mod tests { fn contested_soldier_shading_includes_attacker_pressure() { let cell = test_cell(25, 0, 100); let uncontested = cell_color(&cell, None, MapViewMode::Soldiers); - let contest = ContestedCellView { + let contest = |attacker_strength| ContestedCellView { controller_player: PLAYER_ONE, attacker_player: PLAYER_TWO, - attacker_strength: 75, + attacker_strength, attacker_share: 0.75, }; - let contested = cell_color(&cell, Some(&contest), MapViewMode::Soldiers); + let hue_only = cell_color(&cell, Some(&contest(0)), MapViewMode::Soldiers); + let with_pressure = cell_color(&cell, Some(&contest(75)), MapViewMode::Soldiers); - assert!(contested[..3].iter().sum::() > uncontested[..3].iter().sum::()); + assert!( + hue_only + .iter() + .zip(uncontested) + .any(|(left, right)| (*left - right).abs() > 1.0e-6), + "attacker share should still tint the cell: {hue_only:?} vs {uncontested:?}" + ); + assert!( + with_pressure[..3].iter().sum::() > hue_only[..3].iter().sum::(), + "added attacker infantry should raise soldier intensity at the same mix: {with_pressure:?} vs {hue_only:?}" + ); } #[test] diff --git a/docs/cluster-controls.md b/docs/cluster-controls.md index 9b65021..0888741 100644 --- a/docs/cluster-controls.md +++ b/docs/cluster-controls.md @@ -29,12 +29,18 @@ the command: still. The clicked hex is a focus, not a destination: closer/equal/farther branches receive mild 11/10/9 weights. The allocator gives every branch a positive baseline when the committed integer strength makes that possible. +- Before that click, hovering valid unclaimed ground previews the same command: + participating perimeter cells are highlighted, 11/10/9 weights sit on those + branches, committed Share is labeled, and inland cells that contribute 0 are + dimmed. The hover does not change the dispatch, retask, or enter a ready mode. - Clicking an enemy hex dispatches **Attack Clusters** against that complete enemy traversable cluster. Every shared passable front between the selected sources and selected targets participates. Shift-click stages or toggles several complete enemy clusters without dispatching each one separately; Control-click removes a staged target, a plain enemy click adds it and submits the union, and `Enter` submits the staged union. +- Hovering an enemy cluster previews the full target mask and the shared fronts + that will fire, again without dispatching. Attack does not retain one global direction. The authoritative order snapshots the target-cluster mask and branches from all initially shared fronts. Captured @@ -117,6 +123,21 @@ selected clusters. Confirming stops only that explicit snapshot. `Escape` cancels a staged target, front rebalance, reshape, or stop preview; in idle mode it clears the cluster selection. +## Compact command strip + +The bottom 52px strip is mode-switched. It never dumps the full grammar in one +muted line: + +- Idle with no valid hover: cluster select (`C`) and the contextual click. + Shift/Control modifiers still work; they are not advertised until needed. +- Share and `[` / `]` appear only while hovering a valid expand or attack + target, and in Front Rebalance / ready expand-attack states. +- `T` appears only when exactly one complete cluster is selected and that + cluster has inland free infantry to reshape. +- `X` appears only when live explicit orders intersect the selection. +- `B` appears only when the selection is one complete cluster with at least two + strategic fronts. + ## Deliberate V1 boundary The older sub-cluster Push Front, one-shot formation, and retask-handle grammar diff --git a/docs/playtests/cluster-controls-v1-notes-2026-08-26.md b/docs/playtests/cluster-controls-v1-notes-2026-08-26.md new file mode 100644 index 0000000..0f37b62 --- /dev/null +++ b/docs/playtests/cluster-controls-v1-notes-2026-08-26.md @@ -0,0 +1,25 @@ +# Cluster controls V1 notes — 2026-08-26 + +## Session + +- Method: implementation + automated client tests; offline fixture screenshots + when the native client can render. +- Focus: hover-before-click expand/attack preview and a mode-switched command + strip. No new mechanic, no sub-cluster surgery, no retask. + +## Presentation contract + +| Surface | Expected | +| --- | --- | +| Expand hover | Participating perimeter highlighted, 11/10/9 on those branches, committed Share labeled, inland dimmed (contributes 0) | +| Attack hover | Full target mask plus the shared fronts that will fire | +| Idle HUD | `C` + click only | +| Share | Valid expand/attack hover (and existing ready rebalance/expand/attack modes) | +| `T` | Exactly one complete cluster with inland free infantry | +| `X` | Live explicit orders intersecting the selection | +| `B` | One complete cluster with two strategic fronts | + +Playtest risk 1 (focus-as-destination) remains the gate: the hover must make +all-perimeter expansion unmissable *before* dispatch. Automated tests cover the +preview payload and HUD copy; participant observation still belongs on the +checklist in [`cluster-controls-v1.md`](./cluster-controls-v1.md). diff --git a/docs/playtests/cluster-controls-v1.md b/docs/playtests/cluster-controls-v1.md index 0266e4c..a11b809 100644 --- a/docs/playtests/cluster-controls-v1.md +++ b/docs/playtests/cluster-controls-v1.md @@ -26,7 +26,7 @@ participant did and understood, and attach relevant evidence. | Field | Check | | --- | --- | -| Procedure | Select an owned cluster, hover then click neutral terrain from a cluster with multiple eligible exits. Ask the player what will happen before and after dispatch. | +| Procedure | Select an owned cluster, hover then click neutral terrain from a cluster with multiple eligible exits. Confirm the hover preview (participating perimeters, 11/10/9 weights, Share, inland dim) before asking the player what will happen after dispatch. | | Expected observation | The player identifies the clicked hex as a weighted focus and expects expansion from all eligible selected perimeters, rather than a single-cell movement destination. | | Pass | The participant explains or acts on the all-perimeter behavior without corrective coaching. | | Fail | The participant expects only the clicked cell to receive troops or regards outcomes on other branches as a bug. | diff --git a/docs/v1-ui-brief.md b/docs/v1-ui-brief.md index ce9c004..aba3975 100644 --- a/docs/v1-ui-brief.md +++ b/docs/v1-ui-brief.md @@ -37,10 +37,12 @@ settle, complete, or are explicitly stopped. and multiple disconnected selections. 3. Keep selection coherent when clusters grow, merge, or split. 4. Show neutral clicks as a focus on all-perimeter expansion, not a precise - destination. Communicate stronger toward/equal/away branch weighting without - hiding weak-side expansion. + destination. Hover-before-click must make participating perimeters, 11/10/9 + branch weights, committed Share, and inland-zero unmistakable. Communicate + stronger toward/equal/away branch weighting without hiding weak-side + expansion. 5. Highlight the entire enemy cluster under the pointer and every shared source - front. + front before the attack click. 6. Support Shift staging/toggling and Control removal of several complete enemy target clusters. 7. Display one persisted Share for expansion, attack, and Front Rebalance; @@ -60,13 +62,17 @@ settle, complete, or are explicitly stopped. 14. Adjust the global mobilization target separately from Share. Lowering it stops future conversion but does not demobilize existing infantry. 15. Use a compact keybind-first contextual strip and a `?` field manual, not a - persistent button for every command. + persistent button for every command. Idle shows `C` and click; Share, `B`, + `T`, and `X` appear only in the states where they apply. ## Context states The control strip must have distinct copy for: -- idle selection and contextual click; +- idle selection and contextual click (`C` + click only until a hover or live + context makes Share / `B` / `T` / `X` relevant); +- hover-before-click expand (perimeters, 11/10/9, Share, inland dim) and attack + (full target mask plus firing fronts); - staged enemy targets; - Front Rebalance source/target gesture; - Reshape drawing and ready preview; diff --git a/docs/v1-ui-direction.md b/docs/v1-ui-direction.md index 9a45f98..3b9e530 100644 --- a/docs/v1-ui-direction.md +++ b/docs/v1-ui-direction.md @@ -66,7 +66,9 @@ infantry, and action-committed infantry. Hovering or clicking valid neutral ground shows it as a focus, not a single-cell destination. The preview emphasizes all reachable neutral perimeter exits while using stronger arrows or intensity for branches approaching the focus and -lighter treatment for equal/away branches. +lighter treatment for equal/away branches. Branch weights `11` / `10` / `9` sit +on those exits, committed Share is labeled, and inland cells that contribute 0 +are dimmed before the click. The presentation must avoid implying that the clicked hex alone will be filled. A short label such as “ALL PERIMETERS · FOCUS +q,-r · SHARE 40%” carries the @@ -135,8 +137,9 @@ never promises a rewind, restored territory, or recovered casualties. The compact command strip switches between these states: -1. **Idle / clusters selected** — contextual click hints, Share, `B`, `T`, - `X`, and selection keys. +1. **Idle / clusters selected** — `C` and the contextual click. Share appears + only on a valid expand/attack hover; `B` only with two fronts; `T` only when + one cluster has inland free infantry; `X` only with live orders. 2. **Attack targets staged** — target count, all shared fronts, LMB/Enter submit, Shift toggle, Control remove, Escape back. 3. **Front Rebalance gesture** — source/target strategic fronts and release-to-preview. @@ -199,9 +202,9 @@ yield cleanly to the authoritative snapshot. The first-run hint can fit in four lines: ```text -C selects a whole owned cluster · Shift/Ctrl+C add/remove · Ctrl+A all -LMB neutral expands all selected perimeters · LMB enemy attacks its cluster -B then drag rebalances fronts · [ / ] Share · T reshape · X stop +C selects a whole owned cluster · click after selecting +Hover unclaimed: all perimeters, 11/10/9, Share · hover enemy: mask + fronts +B then drag rebalances fronts when two exist · T reshape inland · X stop live orders ? opens the full field manual ``` From 3bc31d4c871f685658487185f78ca64636d48f01 Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Wed, 26 Aug 2026 14:29:08 +0000 Subject: [PATCH 2/7] Pin offline screenshot hover so expand and attack actually show. The live pointer was overwriting the staged cell, so the attack scene looked like a random unclaimed hover. Forced hover plus a camera look-at keeps the fixture frames on the frontier the HUD is describing. Co-authored-by: Diego Carlino --- crates/game-client/src/camera.rs | 6 ++ crates/game-client/src/interaction.rs | 22 ++++++++ crates/game-client/src/screenshot.rs | 80 +++++++++++++++++---------- 3 files changed, 78 insertions(+), 30 deletions(-) diff --git a/crates/game-client/src/camera.rs b/crates/game-client/src/camera.rs index d496c22..fad11e3 100644 --- a/crates/game-client/src/camera.rs +++ b/crates/game-client/src/camera.rs @@ -27,6 +27,12 @@ impl Default for CameraRig { } } +#[cfg(not(target_arch = "wasm32"))] +pub fn look_at_world(rig: &mut CameraRig, transform: &mut Transform, focus: Vec3) { + rig.focus = focus; + *transform = rig_transform(rig); +} + pub fn spawn_camera_and_light(mut commands: Commands) { let rig = CameraRig::default(); let transform = rig_transform(&rig); diff --git a/crates/game-client/src/interaction.rs b/crates/game-client/src/interaction.rs index d16225c..22df5cd 100644 --- a/crates/game-client/src/interaction.rs +++ b/crates/game-client/src/interaction.rs @@ -413,6 +413,11 @@ fn visible_push_drag(start: Option, current: Option) -> bool { .is_some_and(|(start, current)| start.distance(current) >= PUSH_DRAG_THRESHOLD_PIXELS) } +/// Screenshot / fixture helper: skip live picking and keep this hover. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Resource, Clone, Copy, Debug)] +pub(crate) struct ForcedMapHover(pub Option); + #[derive(Resource, Debug)] pub struct InteractionState { pub hovered: Option, @@ -567,7 +572,24 @@ fn update_hovered_cell( chunks: Query<&TerrainChunk>, mut ray_cast: MeshRayCast, mut interaction: ResMut, + #[cfg(not(target_arch = "wasm32"))] forced_hover: Option>, ) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(forced) = forced_hover.as_deref() { + match forced.0 { + None => { + interaction.hovered = None; + interaction.cursor_world = None; + } + Some(coordinate) => { + let plane = axial_to_plane(coordinate); + interaction.hovered = Some(coordinate); + interaction.last_map_hovered = Some(coordinate); + interaction.cursor_world = Some(Vec3::new(plane.x, 0.5, plane.y)); + } + } + return; + } let pointer_over_ui = pointers.iter().any(|pointer| { pointer .get_nearest_hit() diff --git a/crates/game-client/src/screenshot.rs b/crates/game-client/src/screenshot.rs index 5be76d8..4a92a57 100644 --- a/crates/game-client/src/screenshot.rs +++ b/crates/game-client/src/screenshot.rs @@ -7,13 +7,15 @@ use bevy::{ use hex_core::Axial; use crate::{ + camera::{CameraRig, GameCamera, look_at_world}, config::{ClientConfig, ScreenshotScene}, - interaction::InteractionState, + geometry::axial_to_plane, + interaction::{ForcedMapHover, InteractionState}, model::{MatchView, PLAYER_ONE, PLAYER_TWO}, }; -const WARMUP: u32 = 40; -const SETTLE: u32 = 18; +const WARMUP: u32 = 48; +const SETTLE: u32 = 24; #[derive(Resource, Debug)] struct CapturePlan { @@ -48,10 +50,20 @@ fn stage_and_capture( mut plan: ResMut, mut interaction: ResMut, mut view: ResMut, + camera: Option>>, ) { plan.frames = plan.frames.saturating_add(1); if !plan.staged && plan.frames >= WARMUP { - stage_scene(&mut view, &mut interaction, plan.scene); + let focus = stage_scene(&mut commands, &mut view, &mut interaction, plan.scene); + if let Some(camera) = camera { + let (mut rig, mut transform, mut projection) = camera.into_inner(); + look_at_world(&mut rig, &mut transform, focus); + if !matches!(plan.scene, ScreenshotScene::Idle) + && let Projection::Orthographic(orthographic) = &mut *projection + { + orthographic.scale = 0.52; + } + } plan.staged = true; } if plan.staged && !plan.requested && plan.frames >= WARMUP + SETTLE { @@ -82,53 +94,61 @@ fn drain_exit( } } -fn stage_scene(view: &mut MatchView, interaction: &mut InteractionState, scene: ScreenshotScene) { +fn stage_scene( + commands: &mut Commands, + view: &mut MatchView, + interaction: &mut InteractionState, + scene: ScreenshotScene, +) -> Vec3 { match scene { ScreenshotScene::Idle => { - interaction.hovered = None; interaction.sources.clear(); interaction.source_revision = interaction.source_revision.wrapping_add(1); interaction.invalidate_preview(); + commands.insert_resource(ForcedMapHover(None)); + Vec3::new(0.0, 0.45, 0.0) } ScreenshotScene::ExpandHover => { - let seed = Axial::new(-8, 0); - let cluster = owned_cluster(view, seed); - let focus = cluster - .iter() - .flat_map(|coordinate| coordinate.neighbors()) - .find(|neighbor| { - view.cell(*neighbor) - .is_some_and(|cell| cell.owner.is_none() && view.is_capturable(*neighbor)) - }) - .unwrap_or(Axial::new(-4, 0)); - interaction.sources = cluster; - interaction.hovered = Some(focus); - interaction.last_map_hovered = Some(focus); + let focus = Axial::new(-4, 0); + let seed = Axial::new(-5, 0); + interaction.sources = owned_cluster(view, seed); interaction.source_revision = interaction.source_revision.wrapping_add(1); interaction.invalidate_preview(); + commands.insert_resource(ForcedMapHover(Some(focus))); + world_focus(view, focus) } ScreenshotScene::AttackHover => { + let inland = Axial::new(4, 0); let contact = Axial::new(5, 0); let enemy = Axial::new(6, 0); - if let Some(cell) = view.cells.get_mut(&contact) { - cell.owner = Some(PLAYER_ONE); - cell.infantry = cell.military_capacity.min(40); - } - if let Some(cell) = view.cells.get_mut(&enemy) { + claim_screenshot_cell(view, inland, PLAYER_ONE, 40); + claim_screenshot_cell(view, contact, PLAYER_ONE, 40); + if let Some(cell) = view.cell_mut(enemy) { cell.owner = Some(PLAYER_TWO); } - view.ownership_revision = view.ownership_revision.wrapping_add(1); - view.planning_revision = view.planning_revision.wrapping_add(1); - view.cell_state_revision = view.cell_state_revision.wrapping_add(1); - interaction.sources = BTreeSet::from([contact]); - interaction.hovered = Some(enemy); - interaction.last_map_hovered = Some(enemy); + view.mark_ownership_changed(); + interaction.sources = BTreeSet::from([inland, contact]); interaction.source_revision = interaction.source_revision.wrapping_add(1); interaction.invalidate_preview(); + commands.insert_resource(ForcedMapHover(Some(enemy))); + world_focus(view, contact) } } } +fn claim_screenshot_cell(view: &mut MatchView, coordinate: Axial, owner: u32, infantry: u64) { + if let Some(cell) = view.cell_mut(coordinate) { + cell.owner = Some(owner); + cell.infantry = cell.military_capacity.min(infantry); + } +} + +fn world_focus(view: &MatchView, coordinate: Axial) -> Vec3 { + let plane = axial_to_plane(coordinate); + let elevation = view.cell(coordinate).map_or(1, |cell| cell.elevation); + Vec3::new(plane.x, 0.45 + f32::from(elevation) * 0.18, plane.y) +} + fn owned_cluster(view: &MatchView, seed: Axial) -> BTreeSet { if !view.is_local_owned_passable(seed) { return view From 39531b4a08a59fe3279d1b41b83f1f9df64f707b Mon Sep 17 00:00:00 2001 From: Diego Carlino Date: Wed, 26 Aug 2026 14:33:06 +0000 Subject: [PATCH 3/7] Commit the offline fixture screenshots so GitHub can show them. Idle, expand-hover, and attack-hover belong in docs/playtests/screenshots/ so the PR can render the plastic look and hover HUD from github.com. Co-authored-by: Diego Carlino --- docs/playtests/screenshots/attack-hover.png | Bin 0 -> 1297373 bytes docs/playtests/screenshots/expand-hover.png | Bin 0 -> 1529337 bytes docs/playtests/screenshots/idle.png | Bin 0 -> 1245211 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/playtests/screenshots/attack-hover.png create mode 100644 docs/playtests/screenshots/expand-hover.png create mode 100644 docs/playtests/screenshots/idle.png diff --git a/docs/playtests/screenshots/attack-hover.png b/docs/playtests/screenshots/attack-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..6e2707e8c1101337a3e1810a0db2b42fdef0d5c2 GIT binary patch literal 1297373 zcmaHT33!rcy7tExL?}2CI*m|Tg+lanN|fM~*cugR2T5afI!T%rb1F)3==+A4@KE;X)6jGCxPjQ{--ZRebGUH^4qWck+j zeYX34?)O{q$FZaPJoEfB6h-yPGQ9aGit1HPQAx#Y8sAi%ePbN;Y~&wVZ)*QMpV1Ud z`a+)T4YxinYA&rgzV+j5^NzxJ;K}Pl*Cy{5;;qmM!r$e{CZL6`R~u`tJ}VqKEN52P(0c} zDcQVBG4i6#(x5&h;i=eSPEZoeVN(u;1#k7Vyb(k z&SK;XnMN5WuA_t&K0eCq6*v>#;>T7idD*oTxty@hs917k$mGtprFY9FUw&uh(bEIj z_V4d^r(W1S|F?rOD$12?E5&iR7*bzy4$ZY@scn`#w#clhq4Pp15|cP9Y*R$&B)ySr zK&e=qB#Bw7G|G9j_(A==yE`W-2Fz{VJ+^-Sr%Uuxw!D{Qh*AbWgUd7c``Fz1t{_`s zN)iZ@bSz(x@>rADBALY%q`8Viv)a#ftSa_j37h+$E-a)IH$~YQd?6(@YmAy2Vb6zO zzM+^bjWK8tGHqJ6l`phqytZgv+2dtX793bJJk_G{RydQ@Hn|mNvWRdMw9G=ueEcXS zV2P6GAlv0;C`??wT2h-6X6G8Sqc&$Bb}mbG2uJK0P@6W8YESn%E8KnjZf(?nu~jf_ zrB1D_f{(Y9U2CM6Y5^J4KmJ9YBvDeT6kE_%u4HkneA@BFi)m->Z`e3*NL$9_QB}>~ zD4eC1pqCjaare;$Qf)uIerCFR*{(B51_3{BJ@d!0lLlFL@Yfay@S^lajggYxymq}# zIONvn8&8}VJ(8zT;tFl=Go+N8;0`fS4xUbqIBVj1C_W<9)1o|u8lRUmscRmB-Q*I4VgrvX0NQEn}PT1YBaa~^7g88wt%!of|P}7Nx-4zrwoFicQFiK&XFH{Ox znrMp7c*`_(#IW0kq)hrB3)YzP>X+;u+OO}@KknXZa`2-PoKM8%(LqVVFW^6LoL@e| z!&F{pABo9vO1G;}E=uydQeBoLud~qU?xSM$ zn7hnsPc@097Kg&jaaXufO-Wu~iMvqasc@BBf`&i|KdSdsP?4OF#H?XkSIo>A(P3J8 zw`ynGzm6`Np7i7(KC&Z=!M4hIHdjfIs=-AHIgTJ}b_w~&=IO%1tT3JT!TW>H6m5p( zoS1QN_PoQd3e*Nw*cKE7e(@MsuEGb~#%w>aZJq&Br#6IgybPB!O<5G!IP>+YyY`~T zXC0Goyu;EY#?n(t*$d@%hPxtYSlcN&QW-O3uFlVe@#sS=sZU!IV6t5mxVj)qN~~GP zNz(h%oIz@T?VT$lTU)C;Uf(%O@9hP1(fj)tLIGyjV-4z3LQlU?>)=aaIy(fLVdaL| z(m0)M6v=S4CWqV%SC@)S**2@pp07whSF~jAu@R%%{Vi=D%(Kt@Db1#}`I7fHmUQ-C z+&tsZw(jO3cQd;$yxNd+`@yL*k5AT4ho@6HJ#hfmLY$N$e*dv-TbqsS5|LO)<`sgRLnGtgQ6YyNQpU=gBd2XPoi%~;-IW@d~lxF8=pDqT>a*m zX*-H*-k?ZS7Bgt#*og>Dabr(6O+;<-$5X+hedXAYdSgLdS@9c3hH`kch6G^}vdvOT zVUdeDa;uz~LW`IZhEcAJ%zpGu)#h)Wf3INGq@u%LiTQPM?2h|j;j)7Mp15symdb+? zsaYyFxyva&&C|hd_`($hg3dKNs_tI+boAuTe=Ir8z%7&9fqTGJ@B~pWlkIRhwK}5= z`?|Hptcg;)c6r2!iz35ADGh1?&X+=yS(8YI1uD8L?+$5S8}IIj?%GJtd-jPf zSXPqxw(c{EX|Pcgu8c`D*^1UNo;_nI z9h;I$ofK0bp`?Swb(^NtFBx~sIwSMv#(ldu%L@dTRIOt!t;6Ul+2oCL&zQ{B^9M2{ ze2QjBm=w*|k3CFvv<5g!-DnO)K+$D>sXB-U&ibE~j$WDJke#$-#y96s`#26+wv3As;e zfPY+4$zleYC&)=r314Cw6&#JuR?e3&{d}4!p>}Wi>CTFAkt_b{)_o7#&KRX!jRA=S zgX*E=Cf=mdhhy%JRA+qKzN&G=J5x{ZI*_!J_1-H3`ZxagS?w^!?|R8+m+)&Qa6$SWG%9L!%%3RG~!_nCG=mZnk zjGoaNeC1?AUETh-swz(wNEmdsfUV$gvxFK?iGr{5lm`X4pjw@P<+ezZaCriWo5{*b zvAIi}sUj*Xq-WdR`ECu(^vV?`jv~vStjqYItF~EMwzp|#*`%W9-Fu!6KPAI-2%QvT zd4cB$cPhW`B?l#+y>8DK%we5ecoGvkuP7Efx1{#in+;qa#>v1E$r;9Mn%Fq5S0k*I zIH^2=jUU#rrIbkixuA0L;sNW|=TASp?#A%2cXXun!mP^|`|ijSPn0TgSRwH_l6fUc z!$heM?qqRmX{Mg0;8(e94Sv{^Wh7okHb$Kil=#YR2Qu6H-km=AwU%2~ha!una(EtR ziGu5ti|wgSo*|^e?OUV{r`wlm*Lczm{vcZ_cTpTUN1VlWy0o-C+3)uCGUNz^ZaLqt zkbl?b`6Yi}dTq;$&YPFdY|`7xeQ-=Wd_%&h$-$U#xEh4mpn(zu_>_l|)za<~zR)Ds z2-IxGN0BXC24x;dn^2iIBj?<#G&(zTwS4fiHjIy#PrHNkk$lhb=X7j4C6_Y{o?ZsZ zpn}iXlWo+Bh@5%rv$kh%%-whO+#5R@+>{JX!l0d6qnqTnCm-EfoYcik(b=>$5{Ddb zZ3r+&Ar4Dy5omU$YNHjf;kR;(q&G0Ni z4lcpNr)3&iu0(*~=Of8#5|*YBJ^bpf~EjP{b< zjUUcjJ#J~Q_5mk-AJ%_){#xK@?c) zFV`6P0wI%j_}p5FRsO-@g`clp6ZvO>pztHlOEeRyel zmvo7r?@0%`uuc-uq3m!6B}}#(15f!#=tm)M<97CIJ~*p*dXx6d$gT&c(@|Usz%Xlcaeb|&W-axhlJfOTP;8|5ZOoWo_Zq_wC6r1n&w#>0?f zZ>d~Cl1S6-4;CDobgJaQvKMFW{J86bK>C^ROX`d*+kX4i&HYQSwYj%_UgmW@yYBhX zvX7EUSQAL>lp9$>B5xg|V{0h-$a9Dy-z@4VJbd74q`k3GHXvp3AQJLO>`$bHOV2z> z3Y)Ud4WsOfUD#DshF(n$xwx^*jz5C(! zx6+HcPPE<}Ym#&9sTL`R>$V{4yM1!A+Zkl%8jQ&Ph(bEFX3QtS-Hz2 z7QW{A!nOmAcjKQn4ajW$%aF){`mc{Yjrnj#rUApQ^zdkf6XDl}43NOvxQbu00zodo zbbK4aq>g>O4+iAp?zQp99nra4E8I>Pag9(a_c2opG?FHwXvhNyW|CP*V)QxPT0>3^ zWe}(l6ci3;s?(Lq;VL*tCJcctY@p!@^QJ!V&wudBs^W}I{}Lj+P)|w-8&mILNV!rU z)99A-{K&^Bx^>7D6w~HpI5g~A7tfVk89%e`c+;9ie_nb+E*ZSq$rm!<8jLJT;D=#m zNhqfqRRXbLN~#ILA~;EGJB(JYp*+f^>Wv-CV%H~}th({_G2F5ZXozW3I58PEMWQ67 zoRm0=#qctXI0PA9%3n@_v>=>;oTsxfQ8Sa#w(*TS$}SG7T9(~F>f66qw&q}b@2nYDLwnb6Zv8QDQ|o7I7I#glJ~5^H&@0VT=0|-C zAKty+ee|oFJlZ5foR%sPr2ix2Bc$^Y94JQbt3%Eeb#AP$*nZ;Kf{y18D|dJgFWfbG z@R`Do#+HreCMUB{OiO(KD^ZYA6y`{AXRp&J6>IKiHi-^@wBXLZ>8m#G)wB69KvOo2 z{r=Zk6N!ctYp_%bxke$U@Rj?`ULYA%Y!{PaD{C&Tvn0a1BN3_B6{MqXpEbKBw0KQ( z?!EN|V>idv4gqxG+Fd0m`au!F%ekV2Um+=&2optUD5sW+8d!veVuU1f8l&|Yo0j}I zw7K}UxxYh;{K8)ogCi2}v;mr8PhWD~R1|U)h#o*IlT7L>N4j9LL>w5T81|vU^aVX2 zpti0VRvEu_rTfs->Ek_Vo1^^O-()Xom93gd=_SrWzLz122v|Iu+sELu?R{v5rx0-h z96~51C*-l3gfKhH>twGKFaDVQTPh&sqv5+7&hYPg+pGev$T)r%fnl)tlre< zef0L36FaK(QN-scTO=l)4vs=uSEfa{ra<3_?S~{*U?`T<>%Ig@>pdNVQx*pm+C82)P{2c<*Bi-T9*nE?e?A*#(`Wr3sdYXxBGa#3AUP_`j;g$cN`YGFboB|b96>NFR)xlgZi%l#As3t6X&ft88U%$RGnw-|uF?`~E?exfDB^69E7dCHh#k02uE-=8 zYixxah0Nqi&8-RR3~EmwJsnTcu`ciauVqzN+gFX9VZCL2o=1g&rj_iZOZF!drwMy1 zoQL!wqf{wmrm!$~7ArprB84SddHsXEi+(tzsw8-8?irziiAoy=9M(|)*NhxCu0?@s z5oZal*{U42SY*i+%3Uz|6guu@rqtIqP08E5uxik`hhMB&C|?8COAdZU>!hKAdbV{N0;Pf8QCP+5Qe5`hT zA*_a*%QD+j#U?Z8Y;V5Hou6mqxENV#qe;dLRPby3R%e>Yf;lwX%S)_gP)Tu}KI|!U zIZM+lxzYZ0oAMeK%nYcjTF=H_-I`3xz`F6Nux{bIqyEso?Th=_%|CpV_ZlNpdu-{N zjSa6YIMK3cKkK$^%3C|zygL_MJU@DK)#%&*uI`KkOUEtg!oSR(PXqQhin;xlc64B$ zQjJjtq+u&PGAFri;NaCh1}J)FSl9b%-RA1f^G6#m{T!Z`_O6dQ{Cn5sVT|6LqnE$& z@)tL!H@$vnQTkA}1>j4|CY8UEtuZ=?Wd{YIxq&=}4O`mXReyh4Ks=!RyIA9w6{y$U zzGP22qOS=XL=6(`C!4)4cxkVQ%NJs2WHg`%c??_FedS|7LoIzn1733=(zgLOP6#3~ z$6bo+LnZD|8ras;mv5Y3Su`a&YH4G1SH_0@f4liQE~z{y0ia-kL_v@Ovj%#LHs`W| zpM*@(jUZ6OZE=_T%2CIv*cZMk8u8$yL)?GCiPh68f1|+-@sT@lc|a^jo=X4_WDPl5 z84hPqPtXsL6#*bjwx;Lkqz<#nckqSWL#-#;Hg77fIF@-i!6=BF|9B)Dyko)7AEt~D zsA;Z~Co$QRb%vafL6Q}fprYUjyh>XBq|9S))q!dj`n~4Jl zKRbb)V5FXm6oGInB23AV$-_ax4BdII`Bvjlg%8}EnwFzNr|nN6hrgG>FIOVlb8-zj z!a({kE-IFs<8YTS1^SSO$&#B0tEOVRK}2wFE-W1t8}W1c?#FEvH^-U@)*>j7%nzKE zLh%neEb(RLYRWNR4KpY7hY)n*$fkJ(@%hIOcaA?+qGW@Wp~4G zNdk))ge!Q`;R$hmmIu9H_@ zFIy!Xq#ChG@;Qyjk840vNoPX~MGixAA{}V}mbi9?oKfS~6z-kau;k3FhP~a5V)cNJ zhtlXBI5lh$0T#qPbO1eI+SnX-&_)b8!Zd(Vk#pScULL;0LU5-n`h5qd?e+RO&40_V z(xh&5r7|R369R}Pd7as@}E@$htdb{5(rI?4kBp|V&Sw$zkuv;&po;t+r$Ho4LUl8)z5FmztI zOEWdI*tGcAh>Bh5OK-nj372Na3=*kD!BJSGJdgE=I+C~O;GL^}YHI6*jpp3>c~pVxW*A$R48 zqp$W|U|7Ki26}VF!IqC23ax|H8U3QWwiSL9xw7zu_?-`SP2B#*m{anX!8A(=$pQ1X zgWFOjh$u6l$*kr;yr^zcH2VIzuC=2--_egPA}Aw@CJzNP$Y6t~mGA^?SIN_?0Xwu4 zc1)^FNpNL+>a+U~_GEm9_>aT^;{@%b761tb{R-6n02mTV#PmHsVR!xgZ`u~lZH%^U zE!(KiT>T5HgyY-NIIa?s1V~mguoX2#1j39?a7w*z>l20OX={@8{AT@MO}-2Yc` z%ff4Y#Ap%)3Kssb;O%GozAgo)>jESPznfrlSQ;JMnw2B5@C7MC26BZPg|A$}2X~VK zE|8OFV|q|;&_gyaiO+9&=&#)S$W!+6;$@(S#d4Sr;H%8%)r;(69EDnDH=Im)^ssEz2Zq>B1gW%eyEPW=)1||yZvjp2n5CA!v8k;7B z04VHX${4iF$@B(k5+?SHVOiyn0?>+xPKb7|+~NQfuR#9={5}OH60r!ZrzRv(K4qwt zHF_E*iZE=Ja(&C$8a?gigBJh;x8R$JBd>pW>f_g{T03HGXQpP`66BDk#_!gE4imOG|VVAQ7qr*e#PyxzX4F7f#Jy2Y5>{=nLTRy1?NL?_PLy-zgFZ zwFra)oupP}@H5%D9wizWa;uY%c0Nb$3L>R?e0-HeF15Jie25wnoeyv(maH&H3a`6F zu86*Tc+GWF*}nTbmYwrSXP1z%$G}j85uu3HGnV%olR(Hkfn;_GNHyD)Q!~WP!w1CI z$A>&z`$L;S2qvB|>PGY&RBRbTf@lve40cUuT_o#q5&B35mDU0qNKITfX<)Q3}IYJ=l$)NsZLdghH zkV>;o&u>0==lX86uMQ9RxbeA+!AJM6+}IR7zbd!vzyI{{%!b3)e)^$z_Ltv(7|vSU zOKJ+j3=<46ov;ES$_SmBE!BiUQN=%=d(XM`?Z}lbZN|x&dw!F}0>NZM190L%%%(2H z293v)Fby>}CJi1DeJ2lk+?11tAACoKX3?a9ce~)!S4-D!T=Mz6f{kr#28#ie3o@&Q zLLaSXazto31TkPtp#_6a!+2rxPKp8GETssgp`min5&^jnXt`o8%6S$5-yu)EwKtKeykEi=p_>r_TR%>y(=vcP5_I?Eb3vqlaJJ z?7sBl-0Ic~qaXbkIoMIsd(_8s8~4Tg-@E-o(eQpJCk@{|rA0KZ>qPHSKVae%s4T#$ zBUGckL0Bw?Qc>2t=!fyke!BDX+yaPgp2Zy_NU|yInW9}g?;c$=XU;oSfBH+u`^(z) zyx-^MPYd_HaQ4RyuZ>On$MRqE1yTpP)mf}VdnRtG?%Z@Zd(GI%MIAqvW^gD(V$f1F ziAYxXOiy+|>5c%NfEgO2BGFSrD1vhwc`ijjy$dRwfD1YD=*mcfz+$5z>7d*}qNtg8 zu4-1x`TAJbh3NwV=~Ijpa)b%cf^<1tC7Q5K?+3S54i|UuJQR|@pEO3G7qK%OWCGO! z+tKUhqJ~d8-r0Tr{)JJ^KYM5O z^?gU%3apA+wUCn=O;M>NW)Z{4RX7W&6+6lf9{#|5Ds_nD_veb|?VJBqb?c|Gxm$a&WG1juz;fWPwMJC?`3faP6LmtUajyvQ zO=;v)DLrfrxDkDr<`Qa4c`m1Y#;_&zt7i;fu(FrL?7-nGJ5g0CbN(-D`~) z0u>+~0=>Led0bd4%nQ>omMlh_#qOFh?z9yVX9eL%9nRzl2_CN|XL$swgH}ZfEoK78 zi~6Sh6{F=*f1WQCS@Ki@Vcekd zD}4D`#+U)p61mI;!I{*o@uvEmWYgAZi|-9>vKAlccywJFJJGO#8%m6b zr_ceSjTdE6E(Tu+>57fWo>+5R?lY+Q9jj(mrnAL2Iu}bOkTR$u+|u~8kRh`d2aTNY1~{R(^JTxMG?I}l@iNM3bbxxDL}A+RFQ}q_sh*z zcfKhvR3nLnJ?``zZ>lEBCYl#mNrjwHbG11_OE%q=az)v6<^08|1>-SqJaCO%dnrhf zI*TT=ZxT(2rJ9@s$#+Nxep1Mz4}Y@fh2{e@_wQ+F_@w^QzM*PwiGt@(6UkkLS;Ab9 zE$FLPCb)S9suuDXq7Qo2{Z&iL?kULsI4HAg7C$AQ(<+08HS1_DF+fSc_b8YmzP@dun{*m1)`vU zTztA<$k#1<1_jgw85>^va)cw9rENI&b?>Rg&%QjQNwy%T^M{eT&fY2c)45-?{5?fR zM29sgl9)GDWR_92YPM2mKYn!r`*J%v73 zvfq{N_4QIi$rjTK0u@YeehidYNugA1t_|?~Ad5utlpKGWDSmBxNAbZG8;dtDIp&yr z=X(TL@ZPc*8yy<9LS#`SxGix+r%oX=foask=($vk?NI7zC=a0y@o5F5`)AAo3r4uD zOVgS_jmjopUG;269y=b=7RbH_DnBY#-Kt^AwXD% zNXLrlX(fB`tx0?C|4_gF(dp~s3sUa(vG#Paef*l78mK`~5=eZBP9qdnsF>J6uZ4H% z86*w~BT&xIVJlNq(1UWsM>gJ^Fk;w)((O$pxDpn@p17GB3F#FEDORfjIV9E8ld_^c zNe*}9Lbcgb6oP#q)E8eE+}|bJ^kDb6;%f)SRoy)_elWbuppXVF=}1 zMhYmZ3h*^S2%C(U*Mfil)}ajq8hik%dYo5W7sx=h3& zYE)ORfWp~F?>}$d*A}0#VDFlPk20?IJ^1~9?-;*5g}U?_TSK~88k7S{VQm1wA<@l9 z)CpwD9KrD4Ura08gb9BQX|oFwx^=PjbW8-L34clydg)48l*ojDlumXgE|Od zD=JIfyA{PFe9tScd~{T~_JfzE9oTnL`q9=M?}v3!+_?dLC1;RjLat+;{{Ga=^@E#_ zRy1uNJ!%YO4~5XGit^Bp19 zW%oiK9Ji%fAAT9Fy7*x3xPwji93_Wz#)!^nlB0#EjCw6lpy!aJ@3Qi3sg#)^aaHgs z+6^(b(geEXsCQz+3&rg^KVB=N00g@9(_|7 z^jL{JlFML;Y9(S0Baj;Nqf|qig|G6LTR(BsHPkGK@BH0?&3hf=>%QZxo4G1)$YO)b&ELZle%fk0@S97wXL0T=ZtB=^`u*w2q$lDE8fMMv z4n3@D$+Zq%HD%7*)q5Ebyy{X^$S5@ucRHcJpvWgFLAvQ}wkSz&gjEN1q%lI^ik{|( z?Lm|z?yv67NFRIZ(83|#eeWxqx@E>oAAPdx6C>FfE!sroYO8>xwdU(%KkYqG*?aVw zlP^>?|9J94juOxjOn?g*thoTw1v7@k3aP;=M}vfNxGW9|&?Scr=#xzeIX(Io{{F7R zo97>kEju^w*y6Iq$I-=4QIT#8TI}E~5MinWagYme259}Set<6)KFTfxYq{CxyrAtVLr9B;B84i`k~V<}?Za;rJpuZXA2J8TQ; zLldWg!UoL-<^>ILl>oJo5uB$%2#pMIIptFv$p>xd(^4t)oOQ(i2^WUso=4=|4sAFj ziT&%v!DsHD&uD5sF=PLdd5=V>oFQD#mT=4>d7g(MlOmV6wQ5hfvm%Wni;xmHPgslW zR}0cK;t8rG5kU?;|J(LgBL|M9%?mhP>9ovjKV*m+6-ptpH7Bk%_zT_XP}!fk->IQ) z<}GPmGwRxj^K7M!A^XS7MkR_=JdXX+B5 zMJ)&;e`t*+zVP_abrDxDA;F(hvGd~2`jh7t9Y0*P{p}>Idg4a|2!0BSFV*DZ`z!c> zMtLfO5DExj7PRAe9%O70WbaJ)h5^o{gOtB|4oZuTL!*xGR;TU2fg9IZrRctE!ahD zB;+#m;3Y11FMTM*J$m%Es=CsuHH{e!%Ib^nXGd$)I-|p)aQvr|#Z#z=r~>YU`D;Ce zSUC~#_W@2omI*>a<2ytlKnK0bP=Fs`JgCou9;&_DWG;?`ay*Ch{&GMN+F=PwkYsw< zv@9tzP)?ffy}U#^5%#-FfFT^@eT~j&&N7Q!>FJDL+*L&CpCmtRPH{%Ad)@u ztteQ0wa%MPmU^K~h$1Crg^`H!NC89i41o&wA-@%=JA&mOVL^a0C3#TrNe?GhbX7%8 zH{R-)9%=lvkl{^*+SFTu?cpsfX*^-BkVgo`9GI&L6^unzE1a|XWW(C4sgqybY|TC> zo`1Qz^K!%D?Yn;~Wb|MNlS~njAdTogowK`siEnq&owGxaot&72#DXqJi4__fCEGWD z1_;v>6}3K(yZ&e+E&O!$^Mi~p9CI9y$?ZC^c<37qbN`v*@O`%!$N1#KnH9TcpLnDH zOK><&9_>QNLPl&=TvCB(S3)VFB|2iX{RzsUiHOYYO9Ls#6|YJ{|~Xsis(L1NjT zlIQ^vd>B^fAR^hcdbGNcR3!F%zAe$5cc%AjinMSf!GtxwgF|V{7W`fyjDTYSs6j&F zikKjgYXNE~l*tx%5Q`MxEDRD$e8`eHPgf$=)1$-T zlU{_4Gu*2W}9GK)sa(!8JGOaUOcrc&K`B^d1VdTUdW^s zW{uv+Oktx<&p7UW_XBYnLD9o+N38Y#~d7jd8-yx3z;S9np zy`GK08dR}rSv(;qYnLYdW_^)lg|dI|(_8)!kJnxKq44bdostHRb-#@v-Npip)t+Y>9!c zgjk`J=SlNnVVo;nkHRkK)%pu%q_9)O*eL9Lh3@<~rS~T%R(gQtGGxO%$?)B;>eVTdv&68aHR!(#vxnwQW6Q zz<~pDOu7k5DJZ(sh>H-oWJSXs1(y`QSdl>7S8o($V^Nht?y!K5+kLC~p{;4z#>GEu z+`a$DEu_>Zw-2`z%dMh_Pz=TLN9t*{_N}&@%;a%eLl^15AgX>c`88VT@ z;Vv(%4G@izC*K5pm@Hcm_*|QiM`pn)AJJ)rI@_On{tY*}QQV?r{d+U3h1Y z{-fezkLK;a930<}cO)DFLhymZqC#xm+5&J>N1h`D8H9ASTPb{RTvg$@1HX;O^U4-2 zFGbRkE29QTxS;6DQ`L|$iyDLqj#YuxiO2yW& zK_t>ivnmHaI@&JVyr}c5-X?wuEW=-+FkAQ#2U@IdOGFo~F@zI(E@v9SG=50o6j(my zBA|v4Hgu97i}=K%tZ4s}lX4nnj~iQfIlb>qbxI2OvOEFHEX7V~BBvrQvGa_I#>%yI zK63$gb!X>)Pb@nh?>Dt2_-5>^zX(k1Kl0-Blj`E{-mbj4^ABM) z068uyFA72#`Y?psQpm$l$Em@Ld9ix2FbLrp%gm?=r21SXjBNiQ1o}c}p-3KA*Ql`X z^|Y$ugX=a{j;p@xU9<4_wtS#G3<6UUW6Mksf1%EjLGUGalsZdGe5p`=X+t&okTJ^$ zep(5fg^B~}9)dR$`9~7FHt-2E*8XgU3}V4tTZPh73gnC|M+9At@}R#kOC1jBfxN{| zg_-LD@h7pT`xRWEb;vKlVb^ZkdS2b!jD@kK%SJGB!yauE!pB0cG>=ep90iP4nc~lv zTA4L|9$N$(K!bpd9;n4#a%;_=$>U-NzAvi&^!}1@agUWSQ#BG9k>}F();$yJ+rG&g z8oz$?q;>P&FzV7H&#{g?Vq!t6Rsz6CR+eD-kKU;G@&2SUMIoPZ%;90HWY>%>%fI`Z z_oa@5r+4(_O2og4M$w3&0DoiJtUwp3-6@lgKSR zmBfLSR2dFRWBiwZweDd1r0c21Q`5eky@7SXcwo?XLd}GZ9rxBQs1Bkvg-GL~1~(J_ zYsAS#-|gx<>3D_`r~#QI1*%6>v`DsCVro%DdYCY_FCq5^Vj`UtvUdDAc#4*+UxYUJ z@WyQuMr3xCp4eXE_LVx3;m|FUxR?-qOCuzCIQDE#XFCYTM+ki+8yWggLN0O@A;O*r z%B@WOrN69M`rz5e?O*jB(fQ5rnG3FuekRwStj6kq!V5>fESve#-P=R=|8>v&f8AYH z({*SgHSWNX@k1CLj^WhgHhpmG?qLU4xEOX+<IW_6f_xN|NZOT)AI+_c z%`Pfha=B0C&AO-|kS;)_l#j*TF*>L>2+fqmj`htgW$PfFGs-Ir5_=c&SIuJ}^pSf!0i^ARX+jT>O$xq5GY z9t2kbZAfT#U{P$L-Ca1Ksb!|MFjoMVk_RP>6kVgL7DWf;a+aX=jEI9i3i{urmTXGx z38F9oO)PRkM8v1OB|)&zf+$u1xJo3a{z+!`4#;un4a^0V)y;pMQ5-SKz@OVI%8^a% z*GCj)*ijNzwkLJ9zdfY$-<{)fZdpZ1IUr6-EmCW?jps@CGQ+G~FC?UR^nm); zsA}S|W4aVI4aqY{1@}ucPpPc(#RUv zw{Pl#5esKFeAnJLb?Tf`|GneiH}`A~QsDUPeZb1ND?ph6R&niz+@m9HBMOeKTr-U< z*Y9?fI?*6Pf{z)(q^R~f83_WJtQQU%Kp;9w+>mB_5`}a{c@Vij!SPstaraf+s-NH0 zF=)i)dk#FG!RhKHK|{erISZxCupX2dz>Km{MJThNt8Y^ls) z&Bp3%y~pb61-2;76(+pTxfk=q>oX?isWYt3!d#=2CzOjV?qnNQz2Vvr)>MELAkMxT z9&K0pCyTH6roH<9?_Zv_v16Tm$M`9I*k50~^|0;5qBR?vVkgebUB5mIYBSD`sSSaA zccE1Z%!1}yj0N?(t5=xN8--lAH5;qi1BKE^R3Am8FNCg_z}mR>z+&5#aT zs%T|`E_(>N>~ZVera};ioV@TaEtS-`%3~EvBSLT#$>b3c4lSz|)SNvH#B~bG1vKa6 zdGp7O-bNM>obFh^W8ix z7|)_bE0+zp`f}CGJA$&#`tdszGPLkXvqN}f&e>5*FJ7v@|N7lemyNOhhR zIhI(WVkRwrF46Qt!~o(+(^R#iMWI$-{o=4=i~Cd4u$(@<*R+qFTzul>y6F@FDWsKe z*XGnnP~o$&qdu|b1dV$qIvPQV{b=QiSB^hIa|Lu6!BJUyfSKQ+J%1!$|MyoW^?PyZ zmWDT9*wNP+exdK_V!`OkBR|;E5c=`#yYD(Hc$Y%wOF9SFANYP|{h|{NtRL3_MQ{?b zUUGp-D4LXp5b7hsjcF431*<*4s6ayILJyT=PjvW^Gwj;^A+Tas!HO;ETwJ!`p2N-H zP%;8f09(*Q@g9BD17z%J1jvv?rboXgL5-z~z*SS@FodD=wPONbdby$!

^+Ql(x5t7a^O+}BmEKyNx8dAp`BuK) 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