Draw walls as connected structures
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 11:28 UTC
- commit
- 5ff2fb9164ab243d95a181bfeafbaecd55b193e2
- parent
- 3313af7648
- tree
- browse at this commit
3 files changed +132 -2
| CLAUDE.md | +1 -1 | |
| src/structures.rs | +130 -1 | |
| tasks.md | +1 -0 |
CLAUDE.md +1 -1
| @@ -27,7 +27,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 27 | 27 | - `src/zones.rs` `Zones` resource: stockpile and farm tile sets (`ZoneKind`; stockpile = walkable dry land, farm = `can_farm` fertile; never overlap; farm rendered as zone layer index 1), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion(map, zones, excluded, count)` (excluded = blueprint tiles) grows one tile at a time next to stockpile/planned tiles, preferring tree-free tiles near the stockpile centroid (deterministic tiebreak). `find_stockpile_site` = BFS for nearest fully open (zonable, tree-free) square, fallback any zonable tile. `ZoneTool{mode: Option<ZoneKind>}` (Z stockpile / F farm toggle, Cancel exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`). Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`. |
| 28 | 28 | - `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest/Max = 1x/3x/6x/adaptive via `Time<Virtual>::set_relative_speed`; keys 1/2/3/4 set speed + unpause, Space toggles pause. Max: `SimClock` times the fixed loop (start/stop systems around RunFixedMainLoop) and whole frames; `adapt_max_speed` (`next_max_speed`) aims for ticks filling `MAX_SPEED_BUDGET_SHARE` of a 30 fps frame from measured cost per tick, backs off proportionally on slow frames, smoothed, rise ≤1.5x/frame, clamped `MAX_SPEED_LIMITS`; `max_delta` = frame budget × speed in Max (prevents death spiral), 250 ms otherwise. Info panel shows `Max (Nx)`. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly. |
| 29 | 29 | - `src/light.rs` day/night lighting. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `light_at(kind, source, tile)` falloff from `StructureKind::light()` (None for non-emitters). `LightMap` resource = ambient (roofed tiles get ROOF_DAYLIGHT × daylight) + per-tile source light blocked by walls (`line_of_sight`), recomputed in `update_light_map` (SimSystems::Decide) only when the ambient step (16 levels) changes, structures are added/removed or tiles change, and only replaced when different (a replace refreshes every tilemap chunk); default (empty) = fully lit. `is_lit` = brightness ≥ LIT_BRIGHTNESS. Tilemap tints terrain/plant tiles by `LightMap::color` and refreshes all chunks on `LightMap` change (zones overlay unlit, colonists/items/structures not darkened). |
| 30 | -- `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated. | |
| 30 | +- `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Walls connect: `join_walls` (Update; reruns when the wall/door tile set changes or a wall/door is added) gives each wall `WallJointPiece` children from `wall_joints` (top arm toward each wall/door neighbour, corner fill when both sides and the diagonal are solid), so the light top is continuous and only outer edges show the dark base. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated. | |
| 31 | 31 | - `src/needs.rs` `Rest(f32)` on colonists (1 = rested): `update_rest` (SimSystems::Act) drains 1/24 per game hour awake, recovers 1/7 per hour (1/5 in a bed, ×ROOF_REST_BONUS 1.25 under a roof) while `is_sleeping` (Sleep job, at spot, path empty). `wants_sleep`: rest < NAP_REST (0.3) any time, or night (21–6) and rest < BEDTIME_REST (0.9). `done_sleeping`: rest full and not night (sleep lasts until morning). EXHAUSTED_REST (0.1) makes choose_jobs drop autonomous (not ordered) jobs. `SleepContext::spot` = nearest bed not reserved as `Target::SleepSpot`, else nearest free tile (dry, no plant/stockpile/fire/item, unreserved `Target::SleepSpot`) to the nearest campfire, else home. Sleeping colonists show a `SleepMarker` Text2d "z" child. |
| 32 | 32 | - `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof, Pave}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no non-Pave blueprints; merges its plan into Blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home. |
| 33 | 33 | - Build tool (building.rs): `BuildTool{kind}` `mode: Option<BuildMode::{Place(kind), Deconstruct}>` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, Workbench, Statue, Floor (drag fills a rect via `place_floors`), Deconstruct, off); Deconstruct drag marks structure tiles as `BuildKind::Deconstruct` blueprints (cost 0 → Build task without pickup; completion despawns the structure, clears wall flag, refunds half its cost); Wall drag = room outline (`placements`), others place at the release tile; `place_blueprints` skips plants/walls/structures/water/rock and clears stockpile under walls/doors; shift-drag removes blueprints in the rect. Zone tool and build tool cancel each other; selection ignores clicks while either is active. Colonists build manual blueprints via `find_blueprint_work`. |
src/structures.rs +130 -1
| @@ -15,7 +15,10 @@ impl Plugin for StructuresPlugin { | ||
| 15 | 15 | app.add_systems(Startup, create_structure_assets) |
| 16 | 16 | .add_systems( |
| 17 | 17 | Update, |
| 18 | - clear_structures.run_if(on_message::<WorldGenerated>), | |
| 18 | + ( | |
| 19 | + clear_structures.run_if(on_message::<WorldGenerated>), | |
| 20 | + join_walls, | |
| 21 | + ), | |
| 19 | 22 | ) |
| 20 | 23 | .add_systems(FixedUpdate, burn_fuel.in_set(SimSystems::Act)); |
| 21 | 24 | } |
| @@ -125,6 +128,9 @@ pub struct StructureAssets { | ||
| 125 | 128 | pillow: Part, |
| 126 | 129 | wall: Part, |
| 127 | 130 | wall_top: Part, |
| 131 | + wall_arm_h: Part, | |
| 132 | + wall_arm_v: Part, | |
| 133 | + wall_corner: Part, | |
| 128 | 134 | door: Part, |
| 129 | 135 | door_knob: Part, |
| 130 | 136 | bench: Part, |
| @@ -192,6 +198,18 @@ fn create_structure_assets( | ||
| 192 | 198 | Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.8).into(), |
| 193 | 199 | Color::srgb_u8(132, 128, 122), |
| 194 | 200 | ), |
| 201 | + wall_arm_h: part( | |
| 202 | + Rectangle::new(TILE_SIZE * 0.8, TILE_SIZE * 0.12).into(), | |
| 203 | + Color::srgb_u8(132, 128, 122), | |
| 204 | + ), | |
| 205 | + wall_arm_v: part( | |
| 206 | + Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.8).into(), | |
| 207 | + Color::srgb_u8(132, 128, 122), | |
| 208 | + ), | |
| 209 | + wall_corner: part( | |
| 210 | + Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.12).into(), | |
| 211 | + Color::srgb_u8(132, 128, 122), | |
| 212 | + ), | |
| 195 | 213 | door: part( |
| 196 | 214 | Rectangle::new(TILE_SIZE * 0.9, TILE_SIZE * 0.9).into(), |
| 197 | 215 | Color::srgb_u8(140, 96, 54), |
| @@ -228,6 +246,96 @@ fn create_structure_assets( | ||
| 228 | 246 | }); |
| 229 | 247 | } |
| 230 | 248 | |
| 249 | +const WALL_INSET: f32 = 0.1; | |
| 250 | + | |
| 251 | +#[derive(Debug, Clone, Copy, PartialEq)] | |
| 252 | +pub enum WallJoint { | |
| 253 | + Arm(IVec2), | |
| 254 | + Corner(IVec2), | |
| 255 | +} | |
| 256 | + | |
| 257 | +impl WallJoint { | |
| 258 | + fn offset(self) -> Vec2 { | |
| 259 | + let edge = 0.5 - WALL_INSET / 2.0; | |
| 260 | + match self { | |
| 261 | + WallJoint::Arm(d) | WallJoint::Corner(d) => d.as_vec2() * edge, | |
| 262 | + } | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +pub fn wall_joints(solid: impl Fn(IVec2) -> bool) -> Vec<WallJoint> { | |
| 267 | + let sides = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y]; | |
| 268 | + let mut joints: Vec<WallJoint> = sides | |
| 269 | + .into_iter() | |
| 270 | + .filter(|&d| solid(d)) | |
| 271 | + .map(WallJoint::Arm) | |
| 272 | + .collect(); | |
| 273 | + for d in [ | |
| 274 | + IVec2::ONE, | |
| 275 | + IVec2::new(1, -1), | |
| 276 | + IVec2::new(-1, 1), | |
| 277 | + IVec2::NEG_ONE, | |
| 278 | + ] { | |
| 279 | + if solid(IVec2::new(d.x, 0)) && solid(IVec2::new(0, d.y)) && solid(d) { | |
| 280 | + joints.push(WallJoint::Corner(d)); | |
| 281 | + } | |
| 282 | + } | |
| 283 | + joints | |
| 284 | +} | |
| 285 | + | |
| 286 | +#[derive(Component)] | |
| 287 | +struct WallJointPiece; | |
| 288 | + | |
| 289 | +fn join_walls( | |
| 290 | + mut commands: Commands, | |
| 291 | + assets: Res<StructureAssets>, | |
| 292 | + added: Query<&Structure, Added<Structure>>, | |
| 293 | + mut removed: RemovedComponents<Structure>, | |
| 294 | + structures: Query<(Entity, &TilePos, &Structure)>, | |
| 295 | + pieces: Query<Entity, With<WallJointPiece>>, | |
| 296 | + mut last: Local<std::collections::HashSet<IVec2>>, | |
| 297 | +) { | |
| 298 | + if added.is_empty() && removed.read().count() == 0 { | |
| 299 | + return; | |
| 300 | + } | |
| 301 | + let solid: std::collections::HashSet<IVec2> = structures | |
| 302 | + .iter() | |
| 303 | + .filter(|(_, _, s)| matches!(s.kind, StructureKind::Wall | StructureKind::Door)) | |
| 304 | + .map(|(_, pos, _)| pos.0.as_ivec2()) | |
| 305 | + .collect(); | |
| 306 | + let new_wall = added | |
| 307 | + .iter() | |
| 308 | + .any(|s| matches!(s.kind, StructureKind::Wall | StructureKind::Door)); | |
| 309 | + if solid == *last && !new_wall { | |
| 310 | + return; | |
| 311 | + } | |
| 312 | + *last = solid.clone(); | |
| 313 | + for piece in &pieces { | |
| 314 | + commands.entity(piece).despawn(); | |
| 315 | + } | |
| 316 | + for (entity, pos, structure) in &structures { | |
| 317 | + if structure.kind != StructureKind::Wall { | |
| 318 | + continue; | |
| 319 | + } | |
| 320 | + let tile = pos.0.as_ivec2(); | |
| 321 | + let joints = wall_joints(|d| solid.contains(&(tile + d))); | |
| 322 | + commands.entity(entity).with_children(|c| { | |
| 323 | + for joint in joints { | |
| 324 | + let part = match joint { | |
| 325 | + WallJoint::Arm(d) if d.x != 0 => &assets.wall_arm_v, | |
| 326 | + WallJoint::Arm(_) => &assets.wall_arm_h, | |
| 327 | + WallJoint::Corner(_) => &assets.wall_corner, | |
| 328 | + }; | |
| 329 | + let at = (joint.offset() * TILE_SIZE).extend(0.01); | |
| 330 | + c.spawn(( | |
| 331 | + WallJointPiece, | |
| 332 | + part_bundle(part, Transform::from_translation(at)), | |
| 333 | + )); | |
| 334 | + } | |
| 335 | + }); | |
| 336 | + } | |
| 337 | +} | |
| 338 | + | |
| 231 | 339 | fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle { |
| 232 | 340 | ( |
| 233 | 341 | Mesh2d(mesh.clone()), |
| @@ -334,4 +442,25 @@ mod tests { | ||
| 334 | 442 | assert_eq!(StructureKind::Bed.fuel_ticks(), None); |
| 335 | 443 | assert!(StructureKind::Campfire.fuel_ticks().is_some()); |
| 336 | 444 | } |
| 445 | + | |
| 446 | + // Walls draw a dark tile with a lighter top; the top reaches into every neighbouring wall or | |
| 447 | + // door (and fills the corner where a thick block of wall meets), so a wall reads as one solid | |
| 448 | + // structure outlined only on its outside. | |
| 449 | + #[test] | |
| 450 | + fn wall_tops_join_their_neighbours() { | |
| 451 | + let solid = |tiles: &[(i32, i32)]| { | |
| 452 | + let tiles: Vec<IVec2> = tiles.iter().map(|&(x, y)| IVec2::new(x, y)).collect(); | |
| 453 | + move |d: IVec2| tiles.contains(&d) | |
| 454 | + }; | |
| 455 | + assert!(wall_joints(solid(&[])).is_empty()); | |
| 456 | + let straight = wall_joints(solid(&[(1, 0), (-1, 0)])); | |
| 457 | + assert_eq!( | |
| 458 | + straight, | |
| 459 | + vec![WallJoint::Arm(IVec2::X), WallJoint::Arm(IVec2::NEG_X)] | |
| 460 | + ); | |
| 461 | + let corner = wall_joints(solid(&[(1, 0), (0, 1)])); | |
| 462 | + assert_eq!(corner.len(), 2); | |
| 463 | + let block = wall_joints(solid(&[(1, 0), (0, 1), (1, 1)])); | |
| 464 | + assert!(block.contains(&WallJoint::Corner(IVec2::ONE))); | |
| 465 | + } | |
| 337 | 466 | } |
tasks.md +1 -0
| @@ -44,6 +44,7 @@ | ||
| 44 | 44 | - [x] Housing upkeep: vacant homes kept and reused by new couples, abandoned enclosed homes adopted, blocked doors repaired, homes keep a walkable gap, unreachable blueprints dropped, surplus communal beds deconstructed |
| 45 | 45 | - [x] Colonist cap raised to 20 (26 names); job search pass to keep Max speed up at 20 colonists |
| 46 | 46 | - [x] Wood floors: colonists floor finished homes (mood bonus), Build tool floor mode; light only recomputes on wall/roof changes; search reach fix for far stacks |
| 47 | +- [x] Connected wall rendering (wall tops join neighbouring walls and doors) | |
| 47 | 48 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 48 | 49 | |
| 49 | 50 | ## Planned |