Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions crates/game-client/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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()
},
Expand Down
23 changes: 23 additions & 0 deletions crates/game-client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,

/// Scene staged before `--screenshot` captures.
#[arg(long, requires = "screenshot", value_enum, default_value_t = ScreenshotScene::Idle)]
screenshot_scene: ScreenshotScene,
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -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,
Expand All @@ -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<std::path::PathBuf>,
#[cfg(not(target_arch = "wasm32"))]
pub screenshot_scene: ScreenshotScene,
}

impl ClientConfig {
Expand Down Expand Up @@ -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,
}
}

Expand Down
141 changes: 141 additions & 0 deletions crates/game-client/src/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
);
}
}
}
Loading