diff --git a/crates/game-client/src/camera.rs b/crates/game-client/src/camera.rs index 8b58ad9..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); @@ -45,10 +51,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/geometry.rs b/crates/game-client/src/geometry.rs index 40880a3..49b1f98 100644 --- a/crates/game-client/src/geometry.rs +++ b/crates/game-client/src/geometry.rs @@ -3,6 +3,16 @@ use hex_core::{Axial, ChunkCoord}; pub const HEX_RADIUS: f32 = 0.72; pub const HEX_GAP_SCALE: f32 = 0.975; +/// Circular fillet at each lattice vertex. Small enough to stay a hex. +pub const HEX_FILLET_RADIUS: f32 = HEX_RADIUS * 0.16; +/// Samples along each vertex arc, including both tangent endpoints. +pub const HEX_FILLET_ARC_POINTS: usize = 3; +pub const HEX_OUTLINE_LEN: usize = 6 * HEX_FILLET_ARC_POINTS; +/// Quarter-circle from the flat top onto the vertical wall. Smaller than the +/// plan fillet so the column still reads as a step, not a pebble. +pub const HEX_LIP_RADIUS: f32 = HEX_RADIUS * 0.06; +/// Samples along the top-to-side lip, including both endpoints. +pub const HEX_LIP_ARC_POINTS: usize = 3; pub const ELEVATION_STEP: f32 = 0.36; pub const SEA_LEVEL: f32 = 0.02; pub const COLUMN_FLOOR: f32 = -0.42; @@ -62,6 +72,70 @@ pub fn corner(center: Vec3, index: usize, y: f32) -> Vec3 { ) } +/// Filleted top/outline ring: six cloudy vertices, still a hex. +pub fn filleted_outline(center: Vec3, y: f32) -> [Vec3; HEX_OUTLINE_LEN] { + let sharp = std::array::from_fn::<_, 6, _>(|index| corner(center, index, y)); + let radius = HEX_FILLET_RADIUS; + let tangent = radius / 3.0_f32.sqrt(); + let inset = 2.0 * radius / 3.0_f32.sqrt(); + let mut outline = [Vec3::ZERO; HEX_OUTLINE_LEN]; + for index in 0..6 { + let prev = sharp[(index + 5) % 6]; + let curr = sharp[index]; + let next = sharp[(index + 1) % 6]; + let to_prev = (prev - curr).normalize_or_zero(); + let to_next = (next - curr).normalize_or_zero(); + let start = curr + to_prev * tangent; + let end = curr + to_next * tangent; + let focus = curr + (to_prev + to_next).normalize_or_zero() * inset; + let base = index * HEX_FILLET_ARC_POINTS; + for sample in 0..HEX_FILLET_ARC_POINTS { + let t = sample as f32 / (HEX_FILLET_ARC_POINTS - 1) as f32; + outline[base + sample] = fillet_arc_point(focus, start, end, t); + } + } + outline +} + +pub fn scaled_filleted_point(center: Vec3, point: Vec3, scale: f32) -> Vec3 { + center + (point - center) * scale +} + +/// `t = 0` is the inset top rim; `t = 1` is the outer wall just below the top. +/// The column base is not rounded. +pub fn hex_lip_point(center: Vec3, outline: Vec3, top_y: f32, t: f32) -> Vec3 { + let outward = hex_lip_outward(center, outline); + let radius = HEX_LIP_RADIUS; + let focus = Vec3::new(outline.x, top_y, outline.z) - outward * radius - Vec3::Y * radius; + let theta = t.clamp(0.0, 1.0) * std::f32::consts::FRAC_PI_2; + focus + outward * radius * theta.sin() + Vec3::Y * radius * theta.cos() +} + +pub fn hex_lip_normal(center: Vec3, outline: Vec3, t: f32) -> Vec3 { + let outward = hex_lip_outward(center, outline); + let theta = t.clamp(0.0, 1.0) * std::f32::consts::FRAC_PI_2; + (outward * theta.sin() + Vec3::Y * theta.cos()).normalize_or_zero() +} + +fn hex_lip_outward(center: Vec3, outline: Vec3) -> Vec3 { + Vec3::new(outline.x - center.x, 0.0, outline.z - center.z).normalize_or_zero() +} + +fn fillet_arc_point(focus: Vec3, start: Vec3, end: Vec3, t: f32) -> Vec3 { + let from = Vec2::new(start.x - focus.x, start.z - focus.z); + let to = Vec2::new(end.x - focus.x, end.z - focus.z); + let radius = from.length().max(1.0e-5); + let from = from / radius; + let mut delta = from.angle_to(to.normalize_or_zero()); + if delta > std::f32::consts::PI { + delta -= std::f32::consts::TAU; + } else if delta < -std::f32::consts::PI { + delta += std::f32::consts::TAU; + } + let rotated = Vec2::from_angle(delta * t).rotate(from) * radius; + Vec3::new(focus.x + rotated.x, start.y, focus.z + rotated.y) +} + /// Maps an [`Axial::DIRECTIONS`] index to the matching geometric hex edge. /// /// Axial directions are ordered clockwise, while [`corner`] advances @@ -127,4 +201,71 @@ mod tests { ); } } + + #[test] + fn fillet_radius_is_a_small_fraction_of_the_hex() { + assert!((HEX_FILLET_RADIUS - HEX_RADIUS * 0.16).abs() < f32::EPSILON); + assert!((HEX_FILLET_RADIUS - 0.1152).abs() < 1.0e-4); + assert!((HEX_LIP_RADIUS - HEX_RADIUS * 0.06).abs() < f32::EPSILON); + assert!((HEX_LIP_RADIUS - 0.0432).abs() < 1.0e-4); + const { + assert!(HEX_LIP_RADIUS < HEX_FILLET_RADIUS); + } + } + + #[test] + fn lip_rounds_the_top_edge_and_leaves_the_column_base() { + let center = world_center(Axial::ZERO, 1, false); + let outline = filleted_outline(center, center.y); + let rim = outline[0]; + let inner = hex_lip_point(center, rim, center.y, 0.0); + let outer = hex_lip_point(center, rim, center.y, 1.0); + let mid = hex_lip_point(center, rim, center.y, 0.5); + + assert!((inner.y - center.y).abs() < 1.0e-5); + assert!((outer.y - (center.y - HEX_LIP_RADIUS)).abs() < 1.0e-5); + assert!(mid.y < inner.y && mid.y > outer.y); + let inner_r = Vec2::new(inner.x - center.x, inner.z - center.z).length(); + let outer_r = Vec2::new(outer.x - center.x, outer.z - center.z).length(); + assert!(inner_r + HEX_LIP_RADIUS * 0.5 < outer_r); + assert!((outer.x - rim.x).abs() < 1.0e-5 && (outer.z - rim.z).abs() < 1.0e-5); + assert!(COLUMN_FLOOR < outer.y); + } + + #[test] + fn filleted_outline_rounds_vertices_but_stays_a_hex() { + let center = world_center(Axial::ZERO, 0, false); + let outline = filleted_outline(center, center.y); + assert_eq!(outline.len(), HEX_OUTLINE_LEN); + + let radius = |point: Vec3| Vec2::new(point.x - center.x, point.z - center.z).length(); + for index in 0..6 { + let sharp = corner(center, index, center.y); + let closest = outline + .iter() + .copied() + .map(|point| point.distance(sharp)) + .fold(f32::MAX, f32::min); + assert!( + closest > HEX_FILLET_RADIUS * 0.08, + "vertex {index} is still knife-sharp ({closest})" + ); + + let arc_mid = outline[index * HEX_FILLET_ARC_POINTS + HEX_FILLET_ARC_POINTS / 2]; + let start = outline[index * HEX_FILLET_ARC_POINTS]; + let end = outline[index * HEX_FILLET_ARC_POINTS + HEX_FILLET_ARC_POINTS - 1]; + let next = outline[((index + 1) % 6) * HEX_FILLET_ARC_POINTS]; + let edge_mid = end.lerp(next, 0.5); + assert!( + radius(arc_mid) > radius(start), + "vertex {index} arc folded inward" + ); + assert!( + radius(arc_mid) > radius(edge_mid) + 0.03, + "vertex {index} fillet flattened the hex: arc {} edge {}", + radius(arc_mid), + radius(edge_mid) + ); + } + } } diff --git a/crates/game-client/src/hud.rs b/crates/game-client/src/hud.rs index 7307c3b..dd99409 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("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,24 +606,13 @@ 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!( "{} TARGET HEX{} · SHARE {:>3}%{contextual_status}", interaction.attack_targets.len(), - plural(interaction.attack_targets.len()), + hex_plural(interaction.attack_targets.len()), interaction.amount_percent, ), "Shift+LMB toggle cluster · Ctrl+LMB remove · [ / ] Share · LMB/Enter dispatch union · Esc cancel".to_owned(), @@ -675,7 +662,7 @@ fn command_bar_copy( format!( "{} DESTINATION HEX{} · BRUSH {}x{}+{} · FREE TROOPS · ONE CLUSTER", interaction.shape_targets.len(), - plural(interaction.shape_targets.len()), + hex_plural(interaction.shape_targets.len()), interaction.brush.width(), interaction.brush.height(), interaction.brush.rings(), @@ -697,7 +684,7 @@ fn command_bar_copy( format!( "{} DESTINATION HEX{} · FIT {} / CAP {} · EXACT", interaction.shape_targets.len(), - plural(interaction.shape_targets.len()), + hex_plural(interaction.shape_targets.len()), interaction.preview.reshape_destination_strength, interaction.preview.destination_capacity, ) @@ -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(), + hex_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!("click after selecting{contextual_status} · ? manual") + } + _ => { + format!("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,113 @@ fn set_text(text: &mut Text, value: String) { const fn plural(count: usize) -> &'static str { if count == 1 { "" } else { "S" } } + +const fn hex_plural(count: usize) -> &'static str { + if count == 1 { "" } else { "ES" } +} + +#[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"), + "cluster belongs in the hint column once, not the summary: {summary}" + ); + assert!(summary.contains("click")); + let combined = format!("{summary} {hints}"); + assert_eq!( + combined.matches("C cluster").count(), + 1, + "idle must name cluster once: {combined}" + ); + 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 HEXES")); + 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..22df5cd 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)] @@ -389,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, @@ -480,6 +509,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 } => { @@ -538,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() @@ -1816,13 +1867,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 +2190,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 +2227,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 +2253,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 +2269,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 +2637,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 +2652,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 +4816,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..eb3c99e 100644 --- a/crates/game-client/src/overlays.rs +++ b/crates/game-client/src/overlays.rs @@ -8,25 +8,33 @@ use hex_core::{Axial, ChunkCoord}; use crate::{ camera::GameCamera, - geometry::{chunk_of, corner, edge_index_for_direction, world_center}, - interaction::{InteractionState, OrderMode}, + geometry::{ + HEX_FILLET_ARC_POINTS, chunk_of, edge_index_for_direction, filleted_outline, + scaled_filleted_point, world_center, + }, + 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 +81,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 +89,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 +118,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, @@ -378,9 +391,22 @@ fn draw_region_perimeter( }; let center = point(cell, style.lift); let color = color_for(cell); + let mut exposed = [false; 6]; for (direction, neighbor) in coordinate.neighbors().into_iter().enumerate() { if perimeter_edge_is_exposed(selection, neighbor) { - draw_hex_edge(gizmos, center, direction, style.scale, color); + exposed[edge_index_for_direction(direction)] = true; + } + } + for edge in 0..6 { + if exposed[edge] { + draw_filleted_hex_edge( + gizmos, + center, + edge, + style.scale, + color, + exposed[(edge + 5) % 6], + ); } } } @@ -390,6 +416,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 +600,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 +793,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); + } } } @@ -699,19 +949,49 @@ fn point(cell: &CellView, lift: f32) -> Vec3 { } fn draw_hex(gizmos: &mut Gizmos, center: Vec3, scale: f32, color: Color) { - let points = (0..6).map(|index| { - let full = corner(center, index, center.y); - center + (full - center) * scale - }); + let points = filleted_outline(center, center.y) + .into_iter() + .map(|point| scaled_filleted_point(center, point, scale)); gizmos.lineloop(points, color); } fn draw_hex_edge(gizmos: &mut Gizmos, center: Vec3, direction: usize, scale: f32, color: Color) { - let start_corner = edge_index_for_direction(direction); - let end_corner = (start_corner + 1) % 6; - let start = center + (corner(center, start_corner, center.y) - center) * scale; - let end = center + (corner(center, end_corner, center.y) - center) * scale; - gizmos.line(start, end, color); + draw_filleted_hex_edge( + gizmos, + center, + edge_index_for_direction(direction), + scale, + color, + false, + ); +} + +fn draw_filleted_hex_edge( + gizmos: &mut Gizmos, + center: Vec3, + edge: usize, + scale: f32, + color: Color, + include_start_fillet: bool, +) { + let ring = filleted_outline(center, center.y); + let start_idx = edge * HEX_FILLET_ARC_POINTS + HEX_FILLET_ARC_POINTS - 1; + let end_idx = ((edge + 1) % 6) * HEX_FILLET_ARC_POINTS; + gizmos.line( + scaled_filleted_point(center, ring[start_idx], scale), + scaled_filleted_point(center, ring[end_idx], scale), + color, + ); + if include_start_fillet { + let base = edge * HEX_FILLET_ARC_POINTS; + for sample in 0..HEX_FILLET_ARC_POINTS - 1 { + gizmos.line( + scaled_filleted_point(center, ring[base + sample], scale), + scaled_filleted_point(center, ring[base + sample + 1], scale), + color, + ); + } + } } fn draw_segmented_hex( @@ -721,11 +1001,12 @@ fn draw_segmented_hex( color: Color, segment_fraction: f32, ) { - for direction in 0..6 { - let start_corner = edge_index_for_direction(direction); - let end_corner = (start_corner + 1) % 6; - let start = center + (corner(center, start_corner, center.y) - center) * scale; - let end = center + (corner(center, end_corner, center.y) - center) * scale; + let ring = filleted_outline(center, center.y); + for edge in 0..6 { + let start_idx = edge * HEX_FILLET_ARC_POINTS + HEX_FILLET_ARC_POINTS - 1; + let end_idx = ((edge + 1) % 6) * HEX_FILLET_ARC_POINTS; + let start = scaled_filleted_point(center, ring[start_idx], scale); + let end = scaled_filleted_point(center, ring[end_idx], scale); let middle = start.lerp(end, 0.5); let half = segment_fraction.clamp(0.05, 0.95) * 0.5; gizmos.line(start.lerp(middle, 1.0 - half), middle, color); @@ -896,6 +1177,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..4a92a57 --- /dev/null +++ b/crates/game-client/src/screenshot.rs @@ -0,0 +1,177 @@ +use std::{collections::BTreeSet, path::PathBuf}; + +use bevy::{ + prelude::*, + render::view::screenshot::{Screenshot, save_to_disk}, +}; +use hex_core::Axial; + +use crate::{ + camera::{CameraRig, GameCamera, look_at_world}, + config::{ClientConfig, ScreenshotScene}, + geometry::axial_to_plane, + interaction::{ForcedMapHover, InteractionState}, + model::{MatchView, PLAYER_ONE, PLAYER_TWO}, +}; + +const WARMUP: u32 = 48; +const SETTLE: u32 = 24; + +#[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, + camera: Option>>, +) { + plan.frames = plan.frames.saturating_add(1); + if !plan.staged && plan.frames >= WARMUP { + 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 { + 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( + commands: &mut Commands, + view: &mut MatchView, + interaction: &mut InteractionState, + scene: ScreenshotScene, +) -> Vec3 { + match scene { + ScreenshotScene::Idle => { + 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 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); + 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.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 + .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..2aa95a6 100644 --- a/crates/game-client/src/terrain.rs +++ b/crates/game-client/src/terrain.rs @@ -13,7 +13,11 @@ use bevy::{ use hex_core::{Axial, ChunkCoord, TerrainKind}; use crate::{ - geometry::{COLUMN_FLOOR, cell_top, corner, edge_index_for_direction, world_center}, + geometry::{ + COLUMN_FLOOR, HEX_FILLET_ARC_POINTS, HEX_LIP_ARC_POINTS, HEX_LIP_RADIUS, HEX_OUTLINE_LEN, + cell_top, edge_index_for_direction, filleted_outline, hex_lip_normal, hex_lip_point, + world_center, + }, map_view::{MapViewMode, normalized_cell_value, normalized_soldier_strength}, model::{CellView, ContestedCellView, MatchView}, }; @@ -258,8 +262,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())); @@ -466,60 +471,92 @@ fn push_cell(builder: &mut ChunkMeshBuilder, view: &MatchView, cell: &CellView, let top_y = cell_top(cell.elevation, cell.is_water()); let center = world_center(cell.coordinate, cell.elevation, cell.is_water()); let top_color = cell_color(cell, view.contested_cells.get(&cell.coordinate), mode); + let outline = filleted_outline(center, top_y); + let inner = std::array::from_fn::<_, HEX_OUTLINE_LEN, _>(|index| { + hex_lip_point(center, outline[index], top_y, 0.0) + }); let center_index = builder.vertex(cell.coordinate, center, Vec3::Y, top_color, 1.0); - let top_corners = std::array::from_fn::<_, 6, _>(|index| { - builder.vertex( - cell.coordinate, - corner(center, index, top_y), - Vec3::Y, - top_color, - 1.0, - ) + let rim = std::array::from_fn::<_, HEX_OUTLINE_LEN, _>(|index| { + builder.vertex(cell.coordinate, inner[index], Vec3::Y, top_color, 1.0) }); - for index in 0..6 { - let next = (index + 1) % 6; + for index in 0..HEX_OUTLINE_LEN { + let next = (index + 1) % HEX_OUTLINE_LEN; // Clockwise winding in XZ points the normal toward +Y. - builder.triangle( - cell.coordinate, - center_index, - top_corners[next], - top_corners[index], - ); + builder.triangle(cell.coordinate, center_index, rim[next], rim[index]); } + let mut neighbor_top = [COLUMN_FLOOR; 6]; for (direction, neighbor_coord) in cell.coordinate.neighbors().into_iter().enumerate() { - let neighbor_top = view.cell(neighbor_coord).map_or(COLUMN_FLOOR, |neighbor| { - cell_top(neighbor.elevation, neighbor.is_water()) - }); - let bottom_y = if neighbor_top + 0.015 < top_y { - neighbor_top.max(COLUMN_FLOOR) + neighbor_top[edge_index_for_direction(direction)] = + view.cell(neighbor_coord).map_or(COLUMN_FLOOR, |neighbor| { + cell_top(neighbor.elevation, neighbor.is_water()) + }); + } + + let lip = std::array::from_fn::<_, HEX_OUTLINE_LEN, _>(|index| { + std::array::from_fn::<_, HEX_LIP_ARC_POINTS, _>(|sample| { + let t = sample as f32 / (HEX_LIP_ARC_POINTS - 1) as f32; + let position = hex_lip_point(center, outline[index], top_y, t); + let normal = hex_lip_normal(center, outline[index], t); + let shade_factor = 1.0 - t * 0.08; + builder.vertex( + cell.coordinate, + position, + normal, + shade(top_color, shade_factor), + shade_factor, + ) + }) + }); + for index in 0..HEX_OUTLINE_LEN { + let next = (index + 1) % HEX_OUTLINE_LEN; + for sample in 0..HEX_LIP_ARC_POINTS - 1 { + let d = lip[index][sample]; + let c = lip[next][sample]; + let a = lip[index][sample + 1]; + let b = lip[next][sample + 1]; + builder.triangle(cell.coordinate, a, c, b); + builder.triangle(cell.coordinate, a, d, c); + } + } + + for index in 0..HEX_OUTLINE_LEN { + let next = (index + 1) % HEX_OUTLINE_LEN; + let vertex = index / HEX_FILLET_ARC_POINTS; + let along_arc = index % HEX_FILLET_ARC_POINTS; + let facing_top = if along_arc == HEX_FILLET_ARC_POINTS - 1 { + neighbor_top[vertex] + } else { + neighbor_top[(vertex + 5) % 6].min(neighbor_top[vertex]) + }; + let bottom_y = if facing_top + 0.015 < top_y { + facing_top.max(COLUMN_FLOOR) } else { (top_y - 0.065).max(COLUMN_FLOOR) }; - if bottom_y >= top_y { + let wall_top_y = top_y - HEX_LIP_RADIUS; + if bottom_y >= wall_top_y { continue; } - let edge = edge_index_for_direction(direction); - let next = (edge + 1) % 6; - let top_a = corner(center, edge, top_y); - let top_b = corner(center, next, top_y); - let bottom_a = corner(center, edge, bottom_y); - let bottom_b = corner(center, next, bottom_y); + let top_a = hex_lip_point(center, outline[index], top_y, 1.0); + let top_b = hex_lip_point(center, outline[next], top_y, 1.0); + let bottom_a = Vec3::new(top_a.x, bottom_y, top_a.z); + let bottom_b = Vec3::new(top_b.x, bottom_y, top_b.z); let normal = Vec3::new( (top_a.x + top_b.x) * 0.5 - center.x, 0.0, (top_a.z + top_b.z) * 0.5 - center.z, ) .normalize(); - let depth = ((top_y - bottom_y) / 2.4).clamp(0.0, 1.0); - let side_shade = 0.52 - depth * 0.12; + let depth = ((wall_top_y - bottom_y) / 2.4).clamp(0.0, 1.0); + 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 +596,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 +621,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 +635,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 +648,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 +669,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 +692,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 +788,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 +824,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 +979,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/playtests/screenshots/attack-hover.png b/docs/playtests/screenshots/attack-hover.png new file mode 100644 index 0000000..af3ecb3 Binary files /dev/null and b/docs/playtests/screenshots/attack-hover.png differ diff --git a/docs/playtests/screenshots/expand-hover.png b/docs/playtests/screenshots/expand-hover.png new file mode 100644 index 0000000..1a9d796 Binary files /dev/null and b/docs/playtests/screenshots/expand-hover.png differ diff --git a/docs/playtests/screenshots/idle.png b/docs/playtests/screenshots/idle.png new file mode 100644 index 0000000..14cbf97 Binary files /dev/null and b/docs/playtests/screenshots/idle.png differ 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 ```