Draw walls as connected structures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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