diff --git a/README.md b/README.md index 05696303..6e9785ca 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Nothing appears instantly once people live in the city. Every build order opens The paid contractor puts `CREW_RATE` work points into every open site each tick (4 ticks a day). With contractors alone a park takes about a day, a road a day, a bridge 2.5 days, a police station 3, a hospital 4, a coal plant 9, a stadium 12 and an airport 15. Private developers take about 2 days for houses and 6 for a tower, and run at most `MAX_ZONE_SITES` (10) sites per district at once. Residents with construction skills (construction workers, civil engineers, electricians, water engineers) spend their working day on the nearest site; together they can add up to `HELPER_CAP` (60%) of a contractor shift per tick. They are paid a day rate, remember what they helped build, and show up in the tile inspector's crew list. A building does nothing until it is finished. Sites survive save/load. +Orders you place on foot from the phone go to your own two building crews instead of the contractors. A crew builds one order at a time in real time: 2 minutes for a park up to 5 for the airport at speed 1, and 10 seconds per road tile. A third order waits for the first crew to free up. You can watch them work on site: a foreman by the gate, two builders, and a hauler carrying crates from a pile to the work. A board over the gate names the crew, the order, the stage and the time left. They stand on the ground, among the rebar, on the slab and between the frame's columns, and on the scaffold once the building fills its lot. A crew that takes an order while you play walks in through the gate. + Progress shows continuously, not stage by stage: the view runs each site forward between ticks at its recent work rate, and each stage changes as work goes in. Trees come down and stumps are pulled, the pit widens and the spoil heaps grow, rebar goes in before the slab is poured across, the frame rises floor by floor, safety netting rolls up, asphalt is laid end to end, bridge piers rise, and demolished buildings sink into a growing rubble pile that is then hauled away. The view draws each stage with a fenced dirt lot, an excavated pit with spoil heaps, a wet concrete slab with formwork and rebar, a rising concrete frame (floor by floor), scaffolding with safety netting around the finishing building, and a floodlight. Animated procedural machines work the sites: excavators, JCB-style backhoe loaders, tipping dump trucks, cement mixers with turning drums, tower cranes with slewing jibs and moving trolleys, bulldozers and road rollers. @@ -81,7 +83,7 @@ Founding builds made before anyone lives in the city, and runs with residents of `godot --path .` starts in **first person** at street level. Set `GRIDFALL_VIEW=orbit` to play the orbit city builder with the full HUD described below. -- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in (W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view) and `F` again gets out once it slows down. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. +- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in (W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view) and `F` again gets out once it slows down. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index fd337903..2edc5e32 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -14,6 +14,7 @@ "drive": {"default": true, "description": "First-person feature: get into parked or stopped cars with F and drive them."}, "phone": {"default": true, "description": "First-person feature: B opens the phone Build app, which places roads, zones and buildings for the mayor who owns the land."}, "activity": {"default": true, "description": "First-person feature: AI activity feed, decision beacons and map pings showing which mayor or agent decided what, on which model, from which source, and where; mayors take staggered turns through the day on foot. J cycles the feed, G routes to the newest decision."}, + "crews": {"default": true, "description": "First-person feature: the player's two building crews. Phone orders are built in real time (2-5 minutes for a building) by crews you can watch on site, with a board and a HUD card counting down; later orders queue for a free crew."}, "gmi_live": {"default": true, "description": "Kill switch for live GMI Cloud calls. Off: every call uses the replay cache or heuristics, as with GRIDFALL_OFFLINE=1."}, "analytics": {"default": false, "description": "Local product analytics to user://ops/analytics.jsonl (also GRIDFALL_ANALYTICS=1). Nothing is sent anywhere."}, "error_tracking": {"default": false, "description": "Write contextual error records to user://ops/errors.jsonl (also GRIDFALL_ERROR_LOG=1). In-memory error counters are always on."}, diff --git a/docs/api/index.md b/docs/api/index.md index 668fb0ef..60369369 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -58,6 +58,15 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [chat_text.gd](scripts.fp.chat.md#scriptsfpchatchat_textgd): Text helpers for the global chat and conversations (slice C): the display filter every line passes before it is shown or put into a prompt, dedupe normalisation, the checks the reply validators use (URLs, model names, sentence count), trimming, relative times and initials - [talk_dialog.gd](scripts.fp.chat.md#scriptsfpchattalk_dialoggd): Face-to-face conversation with a resident (slice C §4), and the "Ask someone here" answer +## [scripts/fp/crews/](scripts.fp.crews.md) + +- [crew_bodies.gd](scripts.fp.crews.md#scriptsfpcrewscrew_bodiesgd): The building crews' bodies: HC's trades avatars (roster group "trades"), each under a CharacterRoot scaled to a jittered stature and animated by its gender's clip library with the root motion pulled out, so a clip plays in place and the crew rig moves the worker +- [crew_card.gd](scripts.fp.crews.md#scriptsfpcrewscrew_cardgd): The player's crews on the HUD (feature "crews"), top left under the top bar: how many crews are free, a row per order a crew is on (the crew, the order, the time left, what the crew does now, a progress bar and how far away the site is) and the orders still waiting for a crew +- [crew_grid.gd](scripts.fp.crews.md#scriptsfpcrewscrew_gridgd): The places a crew can stand and walk on one construction site (feature "crews") +- [crew_rig.gd](scripts.fp.crews.md#scriptsfpcrewscrew_riggd): One crew at work on its site (feature "crews"): a foreman beside the gate keeping an eye on things, two builders at the spots nearest the middle and a hauler carrying crates from a pile inside the gate to a drop point by the builders, under a board that names the crew, the order, the stage and the time left +- [crew_spots.gd](scripts.fp.crews.md#scriptsfpcrewscrew_spotsgd): Where a crew works on a construction site, from the site's CrewGrid: the door just inside the gap ConstructionKit leaves in the hoarding toward the frontage road (a waypoint), the foreman's spot to one side of it and a pile to the other, work spots nearest the middle of the site (a little toward the gate) that the door reaches, a drop point by the first work spot with the hauler's way there and back, and the point outside the gate an arriving crew comes from +- [crews_feature.gd](scripts.fp.crews.md#scriptsfpcrewscrews_featuregd): The player's building crews (integration feature "crews") + ## [scripts/fp/drive/](scripts.fp.drive.md) - [drive_feature.gd](scripts.fp.drive.md#scriptsfpdrivedrive_featuregd): Getting into cars and driving them @@ -77,7 +86,7 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [build_ghost.gd](scripts.fp.phone.md#scriptsfpphonebuild_ghostgd): The Build app's marker in the world: on every tile the order would touch, a glowing floor slab and a faint column that reads from down the street, cyan when it can be placed and amber when not, and a ring on the first corner of a road or zone - [build_rules.gd](scripts.fp.phone.md#scriptsfpphonebuild_rulesgd): Read-only previews for the phone Build app - [phone_feature.gd](scripts.fp.phone.md#scriptsfpphonephone_featuregd): The phone and its Build app (integration feature "phone") -- [phone_ui.gd](scripts.fp.phone.md#scriptsfpphonephone_uigd): The Build app's phone: who you build for (the district, its mayor and treasury), the six tools, the building picked for the Build tool, what placing here would do, and the keys +- [phone_ui.gd](scripts.fp.phone.md#scriptsfpphonephone_uigd): The Build app's phone: who you build for (the district, its mayor and treasury), the six tools, the building picked for the Build tool, what placing here would do, the player's crews, and the keys ## [scripts/fp/player/](scripts.fp.player.md) diff --git a/docs/api/scripts.fp.crews.md b/docs/api/scripts.fp.crews.md new file mode 100644 index 00000000..aa1c33f2 --- /dev/null +++ b/docs/api/scripts.fp.crews.md @@ -0,0 +1,244 @@ + + +# `scripts/fp/crews/` + +[Index](index.md) + +## `scripts/fp/crews/crew_bodies.gd` + +extends `RefCounted` + +The building crews' bodies: HC's trades avatars (roster group "trades"), each under a CharacterRoot scaled to a jittered stature and animated by its gender's clip library with the root motion pulled out, so a clip plays in place and the crew rig moves the worker. start() loads the body scenes on background threads; make() returns null until a scene is in, unless `blocking` (tests, photo shots) makes it wait. Without the roster a worker is a hi-vis capsule. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `AP = preload("res://scripts/fp/asset_paths.gd")` +- `GROUP = "trades"` +- `STAND_IN_COLOR = Color(1.0, 0.52, 0.1)` +- `CRATE_COLOR = Color(0.62, 0.47, 0.3)` +- `CRATE_DEPTH = 0.32`: The crate's depth and height (metres on the skeleton); its width follows the hands. +- `CRATE_HEIGHT = 0.28` + +### Properties + +- `kinds: Array[Dictionary]`: Trades bodies that exist: {"name", "gender", "rest" (stature, m), "hip" (m), "path"}. + +### Inner classes + +- `Body`: One worker's body. + +### Functions + +- `func load_roster() -> bool`: Reads the roster's trades bodies and both clip libraries. False (and why) when they are missing; make() then hands out stand-ins. +- `func start() -> void`: Starts loading every trades body on a background thread. +- `func finish_loads() -> void`: Waits for the loads still in flight (quitting with one running aborts it noisily). +- `func make(k: int, jitter: float, parent: Node3D) -> Body`: A body of kinds[k] (any k, wrapped) under parent, its stature picked by jitter (0..1), or null while its scene still loads. Reuses a released body of the same kind first. +- `func release(b: Body) -> void`: Takes b off the street for a later make() of the same kind. +- `func clear() -> void`: Frees the released bodies (teardown). +- `func stature(g: String, jitter: float) -> float`: Stature (m) for a gender and a 0..1 jitter value, from the roster's height targets. +- `func speed(b: Body, clip: StringName) -> float`: World units per second that b covers playing clip (0 for clips that stand still). +- `static func play(b: Body, clip: StringName, blend: float = 0.25) -> void`: Plays clip on b. Asking for the clip it already plays restarts it (a one-shot that ended). +- `static func playing(b: Body) -> bool`: True while b's current clip still runs (loops always do). +- `func add_crate(b: Body) -> void`: Gives b a crate held in both hands of its carry pose (hidden: the rig shows it while carrying). + +## `scripts/fp/crews/crew_card.gd` + +extends `Control` + +The player's crews on the HUD (feature "crews"), top left under the top bar: how many crews are free, a row per order a crew is on (the crew, the order, the time left, what the crew does now, a progress bar and how far away the site is) and the orders still waiting for a crew. The feature fills it through show_jobs(); nothing here reads the sim. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `WIDTH = 300.0` +- `INNER = WIDTH - 23.0`: Text width inside a row: WIDTH less the 10 px side margins and the 3 px stripe. +- `SAFE = 24.0` +- `TOP = 118.0`: Under the compass strip (it ends at y 106 in the 1600x900 base), level with the AI feed. +- `BAR_H = 4.0` +- `MAX_WAITING = 3`: Waiting orders listed by name; the rest are counted. +- `ROW_BG = Color(0.03, 0.04, 0.07, 0.7)` +- `WAIT_BG = Color(0.03, 0.04, 0.07, 0.5)` +- `BAR_BG = Color(1, 1, 1, 0.14)` + +### Functions + +- `func setup() -> void` +- `func show_jobs(rows: Array[Dictionary], free: int, crews: int) -> void`: rows, the orders a crew is on and then the waiting ones: {"id", "crew" (index, -1 while it waits), "label", "stage" (what the crew does now; "" while it waits), "step" ("3 of 5"), "fraction" (0..1), "left" (time left, or until a crew is free), "where" ("240 m NE")}. free: crews with no order, of crews. +- `func row_count() -> int`: Orders on screen with a crew. +- `func row_text(id: int) -> String`: The text of order id's row ("Crew 1 · Police Station · 2:05 · pouring concrete · …"), "" when no crew is on it. +- `func row_fraction(id: int) -> float`: The progress bar's share of order id's row (0..1), -1 when no crew is on it. +- `func waiting_text() -> String`: The waiting orders' lines ("" when none waits). +- `func free_text() -> String`: "1 of 2 free" or "all busy". + +## `scripts/fp/crews/crew_grid.gd` + +extends `RefCounted` + +The places a crew can stand and walk on one construction site (feature "crews"). A grid of cells CELL apart covers the tile and the scaffold round it. At a cell a worker stands on the ground inside the hoarding or on one of the site's decks (ConstructionKit.deck: the slab as poured, the frame's floors, the scaffold's boards; on a deck narrower than a cell, on its middle line), where the collision boxes that box the deck whole (the slab under it, the frame or the scaffold round it) are not in the way. A node is a cell and a height where a worker fits: clear of every other box and cylinder near the tile (CollisionWorld), of the deck's posts and of the decks over it. Nodes link to their neighbours up to CLIMB higher or lower, and straight across up to HOP - 1 cells of something low enough to climb (a berm, the formwork), by points on top of it. path() is the way between two nodes. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `CELL = 0.04`: HALF cells either side of the tile centre, CELL apart: out to 0.52, past the scaffold's boards round the widest footprint (0.9: out to 0.512). A worker stands on the ground only up to GROUND_HALF cells out (0.44), inside the hoarding at 0.47. Neighbouring cells' worker circles overlap, so no part of a site fits between them. +- `HALF = 13` +- `GROUND_HALF = 11` +- `SIDE = HALF * 2 + 1` +- `RADIUS = 0.35 * WS.M`: A worker's footprint radius and height. +- `HEIGHT = 1.8 * WS.M` +- `CLIMB = 1.0 * WS.M`: A worker steps or climbs this much up or down (1 m: onto a slab, over a berm or the formwork), across at most HOP - 1 cells. +- `HOP = 3` +- `EPS = 0.0005` +- `LEVEL_GAP = 0.002`: Two nodes of one cell are at least this far apart in height. +- `SLACK = 0.002`: A box this much short of a deck's edges still boxes it whole. +- `VIA = 1 << 16`: The key of a link's climb points in _via: n * VIA + o. +- `STEPS = [ Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1), Vector2i(1, 1), Vec...`: The eight steps from a cell, straight ones first. + +### Properties + +- `c: Vector3`: The tile centre at the ground datum. +- `cyls: Array[Vector4]`: Cylinders: (x, z, radius, top). +- `decks: Array[Dictionary]`: CityView.site_decks(): {"rect", "y", "t", "posts"}. +- `shells: Array[Dictionary]`: Per deck: {box index: true} for the boxes that box it whole. +- `cell_boxes: Array[Array]`: Per cell (row by row): indices into boxes, cyls and decks near it, and its nodes. +- `pos: Array[Vector3]`: Per node: the feet, the cell, the deck (-1: the ground) and the nodes it links to. + +### Functions + +- `func setup(ctx: FpContext, i: int) -> void`: Lays the grid over tile i as drawn now. +- `func near(p: Vector3, dy := INF) -> int`: The node nearest p within two cells and up to dy above or below it (a height nearer p's counts for more); -1 when none. +- `func stands(p: Vector3) -> bool`: True when p is where a node is: a worker fits there. +- `func taken_by(pts: Array[Vector3]) -> Dictionary`: The nodes ({node: true}) a worker at one of pts, at about its height, would bump into: closer than two worker radii across. +- `func tree(s: int, skip: Dictionary = {}) -> PackedInt32Array`: The parents of every node reached from node s (s is its own; -1 for one it does not reach), not through the nodes in skip ({node: true}). +- `func path(a: int, b: int, skip: Dictionary = {}) -> Array[Vector3]`: The way from node a to node b, not through the nodes in skip: the points to walk through after a, ending at b; [] when b is a or a does not reach it. + +## `scripts/fp/crews/crew_rig.gd` + +extends `Node3D` + +One crew at work on its site (feature "crews"): a foreman beside the gate keeping an eye on things, two builders at the spots nearest the middle and a hauler carrying crates from a pile inside the gate to a drop point by the builders, under a board that names the crew, the order, the stage and the time left. What the builders do follows the stage. A crew that takes an order while you play walks in through the gate. Every place and every way comes from the site's CrewGrid (CrewSpots), so nobody stands or walks in the hoarding, a machine or the frame, and a worker climbs onto a slab or over a berm instead of through it; place() runs again when the site is redrawn for a new stage, and a worker whose spot moved walks over when it can. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `BODIES = preload("res://scripts/fp/crews/crew_bodies.gd")` +- `SPOTS = preload("res://scripts/fp/crews/crew_spots.gd")` +- `ROLES = ["foreman", "builder", "builder", "hauler"]`: A crew, in order: the foreman takes the gate, the builders the work spots, the hauler the pile. +- `STAGE_CLIPS = { "clearing": [&"push", &"pickup", &"crouch_idle"], "excavation": [&"push", &"crouch_id...`: What the builders do in each stage; a looping clip is held HOLD seconds, a one-shot plays out. +- `BUILD_CLIPS = [&"interact", &"crouch_idle", &"interact", &"push"]` +- `FOREMAN_CLIPS = [&"idle_look", &"fold_arms", &"phone", &"idle_look"]` +- `SORT_CLIPS = [&"crouch_idle", &"pickup", &"idle_look"]`: A hauler with no clear way from the pile to the work sorts the pile instead. +- `HOLD = Vector2(3.0, 6.0)` +- `ONE_SHOT_S = {&"pickup": 0.83, &"interact": 2.0}`: One-shot lengths for a worker whose body has not loaded yet. +- `ARRIVE = 0.1 * WS.M`: A worker is at a waypoint within this distance (world units). +- `STAGGER = 0.8`: Seconds between workers stepping off as a crew arrives. +- `TURN = 5.0`: How quickly a worker turns toward where it goes or what it works on (1/s). +- `BOARD_UP = 3.4 * WS.M` +- `BOARD_COLOR = Color(1.0, 0.84, 0.32)` +- `DUST_COLOR = Color(0.8, 0.74, 0.64, 0.38)` +- `NEAR = WS.VIS_PERSON + 0.5`: The crew, its board and its dust show within this distance of the player (bodies end at VIS_PERSON anyway). +- `PICK_R = 0.4 * WS.M`: The interaction ray has to pass within PICK_R of a worker, up to PICK_H above its feet. +- `PICK_H = 1.8 * WS.M` +- `CHEST = 1.3 * WS.M` +- `TALK_S = 3.5`: How long a worker stops to talk after E. + +### Properties + +- `spots: Dictionary`: CrewSpots.layout of the site ({} before place()). +- `stage: String`: The site's stage (a SITE_STAGES name), which picks what the builders do. + +### Inner classes + +- `Worker`: One worker: where it is and what it does. Its body (CrewBodies) appears once it has loaded. + +### Functions + +- `func setup(bodies: BODIES, job: Dictionary, i: int) -> void`: A crew of ROLES for job (a CitySim.crew_jobs entry) on tile i, its bodies from bodies. +- `func place(ctx: FpContext, arrive: bool) -> void`: Lays the crew out on the site as it is drawn now. With arrive the crew walks in from outside the gate; otherwise a worker walks to its new spot when it can get there, or is put there. +- `func step(dt: float, player_pos: Vector3) -> void`: Moves the crew on by dt seconds; it shows only near player_pos. +- `func set_board(title: String, line: String) -> void`: The board's two lines: the crew and the order, then the stage and the time left. +- `func greet(k: int, at: Vector3) -> bool`: Worker k stops what it is doing to talk to the player at `at` for TALK_S. False while it walks. +- `func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary`: The worker the ray from origin along dir passes within PICK_R of, nearest first, up to max_dist: {"k" (index in workers), "t" (distance along the ray), "pos" (its chest)}, or {}. +- `func working() -> int`: How many workers are at work on the site with a body (tests and the self test). +- `func release() -> void`: Gives every body back to the pool (before the rig is freed). + +## `scripts/fp/crews/crew_spots.gd` + +extends `RefCounted` + +Where a crew works on a construction site, from the site's CrewGrid: the door just inside the gap ConstructionKit leaves in the hoarding toward the frontage road (a waypoint), the foreman's spot to one side of it and a pile to the other, work spots nearest the middle of the site (a little toward the gate) that the door reaches, a drop point by the first work spot with the hauler's way there and back, and the point outside the gate an arriving crew comes from. route() is the way between two places on the site. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `GRID = preload("res://scripts/fp/crews/crew_grid.gd")` +- `RADIUS = GRID.RADIUS`: A worker's footprint radius and height for the free test outside the site, and the gap kept between two. +- `HEIGHT = GRID.HEIGHT` +- `APART = 1.4 * WS.M` +- `FENCE_SIDES = [ Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0) ]`: ConstructionKit._fence's sides; without a frontage road the gate is side (CityView's tile hash) % 4. +- `DOOR_IN = 0.4`: The door, DOOR_IN in from the tile centre on the gate side, and the start outside the gate, GATE_OUT out (the hoarding stands at 0.47). +- `GATE_OUT = 0.56` +- `FOREMAN_SIDE = 0.08`: Across the gate from the door: the foreman to one side, the pile to the other. A drop point is at most DROP_REACH from the work. +- `PILE_SIDE = 0.12` +- `DROP_REACH = 0.2` +- `WORK_LEAN = 0.06`: Work spots are the ones nearest a point WORK_LEAN from the middle toward the gate: of two alike (the scaffold's sides), the one in sight of the road. +- `SAMPLE = 0.6 * WS.M`: Step between the points a straight walk outside the site is tested at. +- `NONE = Vector3(NAN, NAN, NAN)` + +### Functions + +- `static func found(p: Vector3) -> bool`: True when p is a point (not NONE). +- `static func gate_side(directory: CityDirectory, i: int) -> Vector2i`: The hoarding's open side on tile i as ConstructionKit draws it: toward the lot's frontage road, else side (CityView's tile hash) % 4 of FENCE_SIDES. +- `static func fenced(s: Dictionary) -> bool`: True for sites ConstructionKit hoards in: all but roads, bridges and added storeys. +- `static func entry_side(ctx: FpContext, i: int) -> Vector2i`: The side a crew comes in from on tile i: the gate of a hoarded site (as drawn, else as it will be), else a side with a finished road, else south. +- `static func layout(ctx: FpContext, i: int, n: int) -> Dictionary`: The crew's places on site i as drawn now: {"middle", "door" (a waypoint, shared), "gate" (the foreman's spot), "out", "pile", "drop" (Vector3, NONE when there is no room), "work": Array[Vector3] (up to n spots, nearest the middle first), "haul" / "back" (Array[Vector3], the way from the pile to the drop and back), "side": Vector2i (entry side), "grid": CrewGrid}. A spot the door does not reach (a scaffold board) is only taken when nothing else is left; without a door on the ground (the finished building fills the hoarding) every spot is reached. +- `static func route(lay: Dictionary, a: Vector3, b: Vector3) -> Array[Vector3]`: The way from a to b over lay's site: the points to walk through after a (from the node nearest a, a step up or down at most, when a is off it), ending at b's node; [] when there is none. +- `static func stands(lay: Dictionary, p: Vector3) -> bool`: True when p is one of the places on lay's site where a worker fits. +- `static func fits(col: CollisionWorld, p: Vector3) -> bool`: True when a person can stand at p (x, z) on the walkable ground (CollisionWorld). +- `static func line_clear(col: CollisionWorld, a: Vector3, b: Vector3) -> bool`: True when a person walks from a to b in a straight line on the ground without running into anything. + +## `scripts/fp/crews/crews_feature.gd` + +extends `FpFeature` + +The player's building crews (integration feature "crews"). Every order placed from the phone (CitySim.apply_player_order) goes to one of D.CREWS crews, which builds it in real time: two to five minutes for a building at game speed 1, while later orders wait for a free crew. The crew is on site to see (CrewRig): a foreman by the gate, builders at work in the middle, a hauler carrying crates, dust while they work and a board over the gate with the crew, the order, the stage and the time left. A crew that takes an order while you play walks in through the gate, and a road crew moves along the road as the work goes on. The crew card under the top bar lists the orders with their stage, progress and countdown, and the ones still waiting. E on a worker gets a word about the job; a toast says when an order is done. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `BODIES = preload("res://scripts/fp/crews/crew_bodies.gd")` +- `SPOTS = preload("res://scripts/fp/crews/crew_spots.gd")` +- `RIG = preload("res://scripts/fp/crews/crew_rig.gd")` +- `CARD = preload("res://scripts/fp/crews/crew_card.gd")` +- `REFRESH = 0.25` +- `PLACE_WAIT = 6.0`: A crew that takes an order waits this long for its site to be drawn before it is placed anyway (on foot CityView holds a rebuild in front of the camera back for up to 5 s). +- `HERE = 0.75`: Within this distance the card reads "here" instead of a distance. +- `DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]` +- `OCCLUDE = 0.3 * WS.M`: Anything the interaction ray meets this much before a worker hides the worker. +- `SAYINGS = { "foreman": [ "We're {stage} on the {label}. Ready in {left}.", "Right on schedule: th...`: What a worker says on E: {stage}, {Stage} (capitalised), {label}, {left}. +- `PHOTO_BACK = 0.62`: The photo shot: the camera stands on the sidewalk this far out from the middle of a lot whose hoarding gate faces a road and looks in through the gate, at an order PHOTO_DONE of the way. +- `PHOTO_DONE = 0.5` +- `PHOTO_BUILDINGS = ["police", "fire_station", "school", "park"]` + +### Properties + +- `rigs: Dictionary`: Order id -> CrewRig, for every order a crew is on. + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func on_world_rebuilt() -> void`: Boot, load or New City: the crews of the city as it is now, already at work. +- `func teardown() -> void` +- `func to_dict() -> Dictionary` +- `func from_dict(d: Dictionary) -> void` +- `func self_test() -> Dictionary` +- `func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary`: The worker nearest along the ray that nothing hides: {"kind": "crew", "id": ":", "tile", "pos" (its chest), "dist", "label", "data": {"job", "worker", "role"}}, or {}. +- `func photo_shots() -> Array`: One shot through the gate of a site a crew works on ("crews"); photo_prepare places the order. +- `func photo_prepare() -> void`: Photo tour: a phone order on the shot's lot, PHOTO_DONE of the way, its site drawn and its crew already at their places. +- `func photo_restore() -> void` diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index 16c3e538..2c512140 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -146,6 +146,8 @@ A first-person feature module owned by one slice. FpMode instantiates it from th - `func pick(_origin: Vector3, _dir: Vector3, _max_dist: float) -> Dictionary`: Interaction pick along a ray (A's interact queries every feature). Return a target dict (§9) or {} when this feature owns nothing on the ray within max_dist. - `func self_test() -> Dictionary`: Called by the FP smoke after the world is up. {"ok": bool, "detail": String}. - `func photo_shots() -> Array`: Extra named eye-level shots for the FP photo tour: [{"name": String, "pos": Vector3 (feet), "yaw": float deg, "pitch": float deg, "hour": float, "prepare": String (optional), "restore": String (optional)}]. "prepare" / "restore" name methods on this feature that the tour calls (no arguments) before the shot is placed and after it is saved, for example forcing rain and clearing it again. +- `func link_signal(sig: Signal, c: Callable) -> void`: Connects c to sig (once) and remembers the pair for unlink_signals(). +- `func unlink_signals() -> void`: Disconnects every pair link_signal() connected whose source is still alive. Call from teardown. ## `scripts/fp/fp_features.gd` diff --git a/docs/api/scripts.fp.phone.md b/docs/api/scripts.fp.phone.md index 3a0d7481..1f2cd3fa 100644 --- a/docs/api/scripts.fp.phone.md +++ b/docs/api/scripts.fp.phone.md @@ -49,16 +49,18 @@ Read-only previews for the phone Build app. Each one mirrors CitySim's own check - `static func owner_of(sim: CitySim, x: int, y: int) -> int`: The mayor index that owns tile (x, y), or -1 for unclaimed land and outside the world. - `static func road_path(a: Vector2i, b: Vector2i) -> Array[Vector2i]`: The L-shaped road from a to b that CitySim builds: along x first, then along y. - `static func preview(sim: CitySim, tool: String, a: Vector2i, target: Vector2i, building: String) -> Dictionary`: What placing tool at target would do. a is the first corner of a two-point tool (D.NO_TILE before it is set). Returns {"ok", "mi", "tiles" (Array[Vector2i] to draw), "cost", "reason", "action" (the apply_action dict)}; reason says what is wrong when ok is false. +- `static func crew_seconds(sim: CitySim, tool: String, building: String, p: Dictionary) -> float`: Crew seconds the previewed order p would take one of the player's crews (CitySim.apply_player_order), or -1 when there is no estimate: zoning and bulldozing (what is on the tile decides), a refused order, or instant construction. ## `scripts/fp/phone/phone_feature.gd` extends `FpFeature` -The phone and its Build app (integration feature "phone"). B opens a phone that orders roads, zones, buildings and bulldozing on the tile the hero looks at. Each order is placed for the mayor who owns that land and paid from that district's treasury, through CitySim.apply_action, so it follows the same rules (and starts the same construction sites) as the mayors' own orders. +The phone and its Build app (integration feature "phone"). B opens a phone that orders roads, zones, buildings and bulldozing on the tile the hero looks at. Each order is placed for the mayor who owns that land and paid from that district's treasury, through CitySim.apply_player_order, so it follows the same rules as the mayors' own orders; the construction sites it opens go to the player's crews, which build in real time (the crews feature draws them; with its flag off orders go to the contractors through apply_action). The phone says how many crews are free and how long an order would take. ### Constants - `D = preload("res://scripts/sim/sim_defs.gd")` +- `S = preload("res://scripts/fp/ui/hud_style.gd")` - `WS = preload("res://scripts/fp/world_scale.gd")` - `RULES = preload("res://scripts/fp/phone/build_rules.gd")` - `PHONE = preload("res://scripts/fp/phone/phone_ui.gd")` @@ -95,7 +97,7 @@ The phone and its Build app (integration feature "phone"). B opens a phone that extends `Control` -The Build app's phone: who you build for (the district, its mayor and treasury), the six tools, the building picked for the Build tool, what placing here would do, and the keys. The feature fills it through show_state(); nothing here reads the sim. +The Build app's phone: who you build for (the district, its mayor and treasury), the six tools, the building picked for the Build tool, what placing here would do, the player's crews, and the keys. The feature fills it through show_state(); nothing here reads the sim. ### Constants @@ -111,5 +113,5 @@ The Build app's phone: who you build for (the district, its mayor and treasury), - `func setup() -> void` - `func set_shown(on: bool) -> void`: Slides the phone in (true) or out. - `func is_shown() -> bool` -- `func show_state(st: Dictionary) -> void`: st: {"district", "mayor", "treasury" (int or null), "tool", "building" (label, "" hides), "line", "ok", "hint"}. +- `func show_state(st: Dictionary) -> void`: st: {"district", "mayor", "treasury" (int or null), "tool", "building" (label, "" hides), "line", "ok", "crews" ("" hides), "hint"}. - `func state() -> Dictionary`: The last state shown (tests read it). diff --git a/docs/api/scripts.fp.ui.md b/docs/api/scripts.fp.ui.md index 292383aa..97884867 100644 --- a/docs/api/scripts.fp.ui.md +++ b/docs/api/scripts.fp.ui.md @@ -448,6 +448,7 @@ Shared look of the on-foot HUD (slice B): colour tokens, fonts with tabular figu - `static func fmt(n: int) -> String`: Thousands separators: 1168 -> "1,168", -3222 -> "−3,222" (true minus sign). Restyled CityHud._fmt. - `static func money(n: int) -> String`: Compact money: "$950", "$3.2k", "$124k", "$1.2M"; a deficit stays exact so it reads at a glance ("−$3,222", true minus sign) until it passes 100k ("−$124k"). - `static func distance(units: float) -> String`: Distance in metres from world units: "8 m", "45 m" (5 m steps from 20 m), "1.2 km". +- `static func countdown(crew_secs: float, speed: int) -> String`: "2:05": crew seconds (sim time) as minutes and seconds of real time at game speed (paused or stepping: as at speed 1). - `static func branded(text: String) -> bool`: True when text matches the model-branding regex (the B contract's no-branding check). - `static func owned_texts(n: Node, out: PackedStringArray = PackedStringArray()) -> PackedStringArray`: Every Label, RichTextLabel and Button text under n that slice B created (OWNER_META). - `static func damp(halflife: float, dt: float) -> float`: Exponential smoothing factor for a halflife in seconds. diff --git a/docs/api/scripts.sim.md b/docs/api/scripts.sim.md index 39c4c6f4..599ab2d1 100644 --- a/docs/api/scripts.sim.md +++ b/docs/api/scripts.sim.md @@ -26,6 +26,8 @@ Tile-based SimCity-style simulation: zoning + demand-driven growth, power/water/ - `site_started(site: Dictionary)` - `site_stage(site: Dictionary)` - `site_finished(site: Dictionary)` +- `crew_job_started(job: Dictionary)`: The player's crews (phone orders, apply_player_order): a crew took an order (also when it leaves the queue), and an order is done, or "cancelled" (true) when its sites were cleared. +- `crew_job_finished(job: Dictionary)` - `tick_progress(phase: String, fraction: float)`: Progress of a sliced tick; phase is a stable name ("recompute", "growth", "construction", "incidents", "residents", "commit", "day_end"); fraction 0..1 within the whole tick. ### Constants @@ -46,6 +48,7 @@ Tile-based SimCity-style simulation: zoning + demand-driven growth, power/water/ - `broken_roads: Dictionary`: Road tiles cut by a downed power line: tile -> incident id. - `active`: Tiles the per-tick systems process: claimed land plus a small margin. Unclaimed wilds hold no roads or buildings, so skipping them keeps a 192x192 world as cheap as the founding city. - `sites: Dictionary`: Open construction sites: tile -> site. "auto" stages construction whenever residents run the city; "staged" / "instant" force it either way. +- `crew_jobs: Array[Dictionary]`: The player's crew orders, oldest first: {"id", "owner", "label", "x", "y", "tiles" (its site tiles), "work" (the sites' contractor work), "total" and "done" (crew seconds), "crew" (crew index, -1 while it waits for a free crew)}. Their sites carry "job", the order id. - `load_remap: Dictionary`: Set by from_dict when the save used an older world layout: {w, h, x0, y0} of that layout, so other systems (residents) can move their saved tile indices across with remap_index. ### Functions @@ -103,6 +106,12 @@ Tile-based SimCity-style simulation: zoning + demand-driven growth, power/water/ - `func site_info(i: int) -> Dictionary` - `func site_summary(d: int, max_n: int = 8) -> Array`: Short lines describing a district's open sites, for prompts and the HUD. - `func total_funds() -> int` +- `func apply_player_order(mi: int, a: Dictionary) -> Dictionary`: apply_action for the player's own orders (the phone). The construction sites an order opens go to the player's crews instead of the contractors: D.CREWS crews build one order each in real time (D.crew_seconds) and later orders wait for a free crew. The result then also has "job", the order's entry in crew_jobs. +- `func advance_crews(seconds: float) -> void`: Moves the player's crews on by seconds of work (real seconds at game speed): their sites go through their stages, finished orders open, and waiting orders get the crews that free up. _process feeds it every frame; skip_to_next_day and tests call it directly. +- `func crew_job_at(i: int) -> Dictionary`: The crew order building tile i, or {} when tile i is not a crew site. +- `func crew_schedule() -> Dictionary`: When each crew order starts and is done, in crew seconds from now: {order id: Vector2(starts_in, done_in)}. Waiting orders go to whichever crew frees up first. +- `func crew_wait() -> float`: Crew seconds until an order placed now would get a crew: 0 while one is free. +- `func crews_free() -> int`: How many of the player's crews have no order. - `func apply_action(mi: int, a: Dictionary) -> Dictionary`: Applies one AI (or player) action for the mayor at index mi. Returns {ok, reason, cost}. - `func enact_ordinance(key: String) -> bool` - `func repeal_ordinance(key: String) -> bool` @@ -263,6 +272,11 @@ Static rules, costs, and catalogs for the GridFall city simulation. - `SITE_STAGES = { "build": ["clearing", "excavation", "foundation", "frame", "finishing"], "light": ["c...` - `STAGE_LABELS = { "clearing": "clearing the land", "excavation": "digging the foundations", "foundation...` - `LIGHT_BUILDS = ["solar_farm", "wind_farm", "landfill"]`: Light structures skip the excavation stage. +- `CREWS = 2`: The player's own building crews, for orders placed from the phone. A crew takes one order at a time and works it in real seconds at game speed; later orders wait for the next free crew. +- `CREW_MIN_S = 120.0`: A park takes a crew CREW_MIN_S, the airport CREW_MAX_S, other buildings scale with their contractor time in between. Roads and clearing take CREW_TILE_S per tile. No order is quicker than CREW_JOB_MIN_S or slower than CREW_MAX_S. +- `CREW_MAX_S = 300.0` +- `CREW_JOB_MIN_S = 45.0` +- `CREW_TILE_S = {"road": 10.0, "bridge": 24.0, "clear": 30.0, "haul": 45.0}` - `R_POP = [0, 16, 48, 120]` - `C_JOBS = [0, 10, 30, 72]` - `I_JOBS = [0, 14, 40, 90]` @@ -288,6 +302,9 @@ Static rules, costs, and catalogs for the GridFall city simulation. - `static func is_zone(kind: int) -> bool` - `static func is_service(kind: int) -> bool` - `static func build_ticks(key: String) -> int`: Contractor-only build time in growth ticks (4 per day) for a service building. +- `static func crew_seconds(plan: String, ticks: int) -> float`: Real seconds one of the player's crews needs for a site of plan that contractors alone would finish in ticks growth ticks. +- `static func build_plan(key: String) -> String`: The site plan for building key: the park and the light builds go through fewer stages. +- `static func build_crew_seconds(key: String) -> float`: Crew seconds for one building of type key, from the order to the opening. - `static func is_power_plant(kind: int) -> bool` - `static func building_key_for_kind(kind: int) -> String` - `static func mayor_index(mayor_id: String) -> int` diff --git a/docs/api/scripts.world.md b/docs/api/scripts.world.md index d4e7bc95..cb8403eb 100644 --- a/docs/api/scripts.world.md +++ b/docs/api/scripts.world.md @@ -153,6 +153,8 @@ Renders the CitySim grid in 3D: Kenney roads picked by neighbor mask, zone lots - `func warm_paths() -> Array`: Scene paths a growing city can need on foot: every zone level option and service model. - `func building_aabb(i: int) -> AABB`: World-space bounds of the building model on tile i (zone or service); AABB() (size zero) when the tile has no building model: roads, rubble, sites without a model, empty land, woods. - `func site_boxes(i: int) -> Array[AABB]`: World-space blocking boxes of the construction site drawn on tile i, one per entry of the `colliders` meta that ConstructionKit.build_site writes on the holder's "Site" node before tile_built fires: hoarding segments (with the gate gap), machine bodies (stretched over their shuttle run, without the swinging arms), the crane's base and mast, the floodlight pole, slabs, formwork, berms, mounds, the frame, the scaffold and the old or finished building. Each entry is a box yawed about +Y; this returns the box of its rotated corners. Parts can reach past the tile (a tower crane's base up to 0.2 u into the neighbours). [] when no site is drawn on tile i. +- `func site_gate(i: int) -> Vector2i`: The open side of site i's hoarding (ConstructionKit._fence: toward the frontage road), or Vector2i.ZERO without a fenced site drawn on tile i. +- `func site_decks(i: int) -> Array[Dictionary]`: Where people stand on site i's own parts as drawn now (ConstructionKit.decks_of: the pit, the slab as poured, the frame's floors shown so far, the scaffold's boards), in world space. - `func site_bounds(i: int) -> AABB`: Merged world-space bound of site_boxes(i): contains every blocking part the construction kit drew for the site on tile i, and can reach past the tile. AABB() when no site is drawn. For movement use site_boxes(i): this bound also covers the gaps between parts (the hoarding gate). - `func tile_holder(i: int) -> Node3D`: The Node3D holding everything drawn on tile i, or null. - `func set_camera(c: Camera3D) -> void`: Camera used for picking, shake and avatar picking (orbit rig camera or the FP camera). @@ -184,6 +186,8 @@ Procedural construction sites and machines: staged ground works, rising concrete - `HOARDING_H_FP = 2.2 * WS.M` - `HOARDING_TINTS = [Color(0.33, 0.43, 0.34), Color(0.36, 0.4, 0.45), Color(0.86, 0.7, 0.5)]`: Hoarding paint per site (hash): green, slate, raw plywood. - `HOARDING_IN = 0.46`: Machines keep their footprint inside the hoarding at +-HOARDING_IN. +- `DECK_IN = 0.025`: A crew among the rebar keeps this far inside the footprint (clear of the formwork). +- `SLAB_H = 0.03`: The slab a frame stands on. - `GRIME = "res://textures/real/decals/"` ### Functions @@ -203,6 +207,8 @@ Procedural construction sites and machines: staged ground works, rising concrete - `func solid(n: Node3D, arms := true) -> Node3D`: Marks n (all its meshes, at their built size) as a part of the site being built that blocks a walker. Without `arms`, subtrees named "Boom" (the swinging digger and backhoe arms) are left out. Colliders are baked once per build and do not follow progress, so parts that progress hides or reveals one by one (felled trees, stumps, rebar, debris, planting) are not marked; the frame is, since its first floor always stands. Every new blocking part must be marked. - `func solid_box(n: Node3D, local: AABB) -> void`: Marks an explicit blocking box, given in n's frame (the crane's base and mast, not its jib). - `static func collider_aabb(c: Dictionary) -> AABB`: Holder-space AABB of one `colliders` entry: the box of its corners, rotated by yaw about +Y around `pos`, the centre of its base. +- `func deck(n: Node3D, rect: Rect2, y: float, t: float, posts: Array[Rect2] = []) -> void`: Marks where people stand on the site being built (crews on foot): rect (xz) at height y, the top of a part t thick, in n's frame, there while n shows. posts (xz, in the Site's frame) are kept clear on it (columns, a core). +- `static func decks_of(site: Node3D, xf: Transform3D) -> Array[Dictionary]`: A built Site root's decks as drawn now, in the frame xf puts its holder in: [{"rect": Rect2 (xz), "y": the top, "t": the thickness, "posts": Array[Rect2] (xz)}]; hidden ones are left out. - `func track(n: Node3D, mode: String, f0 := 0.0, f1 := 1.0, a := 0.0, b := 1.0) -> Node3D`: Ties part of the site being built to stage progress. Between f0 and f1 the node scales from a to b times its built size ("sx", "sy", "sxz"), or shows ("reveal") / removes ("hide") its children one by one. ## `scripts/world/gridfall_world.gd` diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 8de77206..ec869900 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -84,6 +84,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th `FpFeature` lifecycle (base class `scripts/fp/fp_feature.gd`): `setup(ctx: FpContext)` → `on_world_rebuilt()` (after `view.setup` re-runs on load or new city) → `to_dict()` / `from_dict(d)` (saved under `save["fp"][feature_id]`) → `teardown()`. +`link_signal(sig, c)` connects a feature to a long-lived object's signal and remembers the pair; `unlink_signals()` in `teardown()` disconnects them all. ## 4. Seams S0 adds to shared or legacy files (the only cross-slice hunks) @@ -98,6 +99,8 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - S0's own FP files also carry shared contracts that slices build on: `GameBus.card_action`, `CityDirectory.planned_name`, `CollisionWorld.boxes()`, `AP.load_scene()` and the GLB node-name prefixes, `FpPlayer.photo_mode`, and the `fp_kit` test context. S0 §23 (D-S0-17 to D-S0-22) lists them. - `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. The phone adds `phone_toggle` (B); while it is open it reads 1-6, the mouse buttons and the wheel itself. The AI activity feed adds `ai_feed` (J, keyboard only: every pad button is taken on foot) and reuses `site_waypoint` (G) on foot to route to the newest decision; the pad's Y keeps opening the map. - `city_brain.gd` (AI activity): `stagger_mayors` (the activity feature sets it on foot, and teardown restores it). When it is on, `_on_day_started` queues the day's mayor turns in `turns` (`scripts/ai/mayor_turns.gd`, in an order that rotates daily) instead of running them at dawn. `_process` starts the k-th turn once `sim.day_progress()` reaches `MayorTurns.at(k)`, which spreads the turns over the first 90% of the day, and only while no sliced job or spread work is in flight (`MayorTurns.sim_idle`), so its actions never force a synchronous finish. `_on_day_ended` runs any turn still waiting; a mayor still deciding keeps the turn and takes it in `_day_gate` once free. `turns.next_turn()` reads the queue, and saves keep it under `turns` (`due` and `day`). Decision entries also carry `proposal` (the ordinance proposed this turn) and `votes` (the votes cast this turn). `GameBus.ping` reasons starting `ai:` are coloured by `hud_style.ping_color()` on the minimap and compass, and `FpHud` does not toast them. +- `city_sim.gd` and `sim_defs.gd` (crews): `apply_player_order(mi, a)` is `apply_action` for the phone. The sites an order opens go to the player's `D.CREWS` crews instead of the contractors, and the order is kept in `crew_jobs` (`total` and `done` in crew seconds, `crew` -1 while it waits). `advance_crews(seconds)` moves the crews on. `_process` feeds it the frame time at game speed, holding time back while a sliced tick or spread work runs. Its sites go through their stages, finished orders open, and waiting orders take the crews that free up (`crew_job_started` / `crew_job_finished`, `cancelled` when a bulldoze clears the sites). `crew_schedule()`, `crew_wait()`, `crews_free()` and `crew_job_at(i)` answer the HUD, and saves keep the orders. `D.crew_seconds` / `D.build_crew_seconds` give the times: `CREW_MIN_S` (a park) to `CREW_MAX_S` (the airport) for buildings, `CREW_TILE_S` per road or bridge tile. +- `construction_kit.gd` and `city_view.gd` (crews): `ConstructionKit.deck()` marks where people stand on a site's own parts: the ground among the rebar, the slab as poured, the slab a frame stands on (`SLAB_H`), every frame floor (each storey at least `FLOOR_H`) and the scaffold's boards, with the posts to keep clear. `decks_of()` reads the decks that show. The hoarding's gate faces the frontage road (else side `h % 4`) and is the `Site` root's `gate` meta. Each spoil berm blocks on its own, so the pit inside is open ground. `CityView.site_gate(i)` and `site_decks(i)` (world space) read them. - `FpMode.DEFAULT_SETTINGS` (D13): `camera_mode: "third"`, `tps_fov: 60.0`, `tps_distance_m: 3.2`, `shoulder: 1` (right; -1 left), `auto_shoulder: false`, `hero: "m"` (or `"f"`). `"fov"` stays the first-person FOV. `apply_settings(patch, persist := true)`: `persist = false` changes the settings for this run without writing `user://gridfall_fp_settings.json` (the photo tour and `fp_bench` use it). ## 5. Slices (built in parallel after S0) @@ -118,8 +121,9 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **I Audio** | `scripts/fp/audio/**`, `sounds/fp/**`, `scripts/audio_director.gd` | 3D positional engines, horns and sirens, footsteps by surface (in step with the hero's stride when the avatar animates), area ambience (traffic hum, park birds, river, construction), and a UI chat blip. Assets synthesized procedurally or with the existing ACE-Step tool. | | **Integration: city overview** | `scripts/fp/overview/**`, `tools/tests/fp_overview_test.gd` | Tab on foot: an aerial camera over the city (through `FpPlayer.set_view_override`, so `player.camera` stays the one camera) with a marker per open construction site and a site list. N / B pick sites, F walks the hero to a vantage point by the site, G sets a waypoint, Tab or Esc return. "Planner view" leaves for orbit; Tab in orbit comes back to where the hero stood (`Main._fp_saved`). | | **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip, crashes bounce off `CollisionWorld` boxes. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room; a left car is removed once out of range and out of sight. | -| **Integration: phone** | `scripts/fp/phone/**`, `tools/tests/fp_phone_test.gd` | B opens a phone with a Build app: road, homes, shops, industry, a building from the service list, and bulldoze, on the tile ahead of the hero (the camera's pitch sets the distance, 0.6 to 6 tiles). Each order goes through `CitySim.apply_action` for the mayor who owns that tile, so it is paid from that district's treasury and follows the mayors' rules and construction sites; unclaimed land is refused. `build_rules.gd` mirrors the sim's checks for the preview: a cyan or amber ghost over the tiles, the owner, the price and why not. Roads and zones take two clicks (start or corner, then end). Put away while driving or under a menu. | +| **Integration: phone** | `scripts/fp/phone/**`, `tools/tests/fp_phone_test.gd` | B opens a phone with a Build app: road, homes, shops, industry, a building from the service list, and bulldoze, on the tile ahead of the hero (the camera's pitch sets the distance, 0.6 to 6 tiles). Each order goes through `CitySim.apply_player_order` for the mayor who owns that tile, so it is paid from that district's treasury and follows the mayors' rules. Its construction sites go to the player's crews (below; with the `crews` flag off, `apply_action` hands them to the contractors). Unclaimed land is refused. A line on the phone says how many crews are free and how long the order would take, or when a crew frees up for it. `build_rules.gd` mirrors the sim's checks for the preview: a cyan or amber ghost over the tiles, the owner, the price and why not. Roads and zones take two clicks (start or corner, then end). Put away while driving or under a menu. | | **Integration: AI activity** | `scripts/fp/activity/**`, `tools/tests/fp_activity_test.gd` | Shows the city's AI at work from the street. Every mayor turn, council ruling, Chaos Director pick, Grid Operator order and Gazette front page lands as a card in a feed at the top right (J cycles full, brief and hidden). A card names who decided and the model they run on, and says whether the answer came live, from a replay, from a fallback after a failed live call, or from the offline planner. It also shows what they did and why, any votes, and where. A decision with a place raises a light beacon in the agent's colour (tagged within 30 tiles) and pings the compass and minimap (`ai:`, no toast). G routes the GPS to the newest decision with a place. On foot it turns on `CityBrain.stagger_mayors`, so the five mayors take their turns spread over the day and the header line says who is deciding or who goes next. The feed hides under modals (not under the chat input), and the beacons hide in photo mode. | +| **Integration: crews** | `scripts/fp/crews/**`, `tools/tests/fp_crews_test.gd`, `tools/tests/fp_crew_jobs_test.gd` | Phone orders go to the player's two building crews, which build them in real time: 2 to 5 minutes for a building at speed 1, roads by the tile. A third order waits for a free crew. Each crew is on its site to see (`crew_rig.gd`): a foreman by the gate, two builders whose clips follow the stage, and a hauler carrying crates from a pile to the work. They use the trades bodies from HC's roster (`crew_bodies.gd`, loaded in the background), with dust while they work and a board over the gate naming the crew, the order, the stage and the time left. Where they stand and walk comes from the site's `CrewGrid` (`crew_grid.gd`). It is a grid of cells 0.8 m apart with nodes on the ground inside the hoarding and on the kit's decks (on a narrow scaffold board, along its middle). Each node is clear of every collision box and cylinder near the tile, except the boxes that enclose its deck (the slab under it, the frame or scaffold round it), and of the deck's posts and of the decks above. Nodes link by steps up to 1 m and climbs over berms and formwork. `crew_spots.gd` picks the door inside the gate, the foreman's spot and the pile beside it, and work spots nearest the middle that the door reaches. It also picks a drop point the hauler reaches without walking into the crew. Where a finished building fills its lot, the crew works from the scaffold. A crew that takes an order while you play walks in through the gate. The crew is laid out again when its site is redrawn for a new stage (a worker walks over when it can), and a road crew moves along the road. A card under the top bar lists the orders with their stage, progress and countdown, and the ones waiting. E on a worker gets a word about the job, the site's interact label says which crew builds it, and a toast says when an order is done. | ## 6. Rules for every slice diff --git a/scripts/fp/crews/crew_bodies.gd b/scripts/fp/crews/crew_bodies.gd new file mode 100644 index 00000000..8d8d0ac4 --- /dev/null +++ b/scripts/fp/crews/crew_bodies.gd @@ -0,0 +1,315 @@ +extends RefCounted +## The building crews' bodies: HC's trades avatars (roster group "trades"), each under a +## CharacterRoot scaled to a jittered stature and animated by its gender's clip library with the +## root motion pulled out, so a clip plays in place and the crew rig moves the worker. start() +## loads the body scenes on background threads; make() returns null until a scene is in, unless +## `blocking` (tests, photo shots) makes it wait. Without the roster a worker is a hi-vis capsule. + +const WS := preload("res://scripts/fp/world_scale.gd") +const AP := preload("res://scripts/fp/asset_paths.gd") +const GROUP := "trades" +const STAND_IN_COLOR := Color(1.0, 0.52, 0.1) +const CRATE_COLOR := Color(0.62, 0.47, 0.3) +## The crate's depth and height (metres on the skeleton); its width follows the hands. +const CRATE_DEPTH := 0.32 +const CRATE_HEIGHT := 0.28 + + +## One worker's body. +class Body: + extends RefCounted + var root: Node3D = null + var skel: Skeleton3D = null + var ap: AnimationPlayer = null + ## BoneAttachment3D with the carried crate (add_crate), hidden until the rig shows it. + var crate: Node3D = null + var gender: String = "m" + ## Index into kinds, -1 for the stand-in. + var kind: int = -1 + ## World units this body covers per unit of a clip's hip-relative speed (hip height times the + ## CharacterRoot scale). + var stride: float = WS.M + var clip: StringName = &"" + + +var ok: bool = false +var why: String = "not loaded" +var blocking: bool = false +## Trades bodies that exist: {"name", "gender", "rest" (stature, m), "hip" (m), "path"}. +var kinds: Array[Dictionary] = [] +var made: int = 0 +var _libs: Dictionary = {} +## gender -> {clip: speed_mps / speed_ref_hip_m} +var _clip_speed: Dictionary = {} +## gender -> [crate transform in the UpperChest bone's frame, crate BoxMesh] +var _crate_fit: Dictionary = {} +var _height: Dictionary = {"target_m": 1.75, "female_factor": 0.95, "jitter": [0.97, 1.03]} +var _loading: Dictionary = {} +var _loaded: Dictionary = {} +var _pool: Dictionary = {} +var _crate_mat: StandardMaterial3D = null + + +## Reads the roster's trades bodies and both clip libraries. False (and why) when they are missing; +## make() then hands out stand-ins. +func load_roster() -> bool: + kinds.clear() + ok = false + var raw := FileAccess.get_file_as_string(AP.CHAR_ROSTER) + var parsed: Variant = JSON.parse_string(raw) if not raw.is_empty() else null + if typeof(parsed) != TYPE_DICTIONARY: + why = "no character roster" + return false + var roster: Dictionary = parsed + var all: Dictionary = roster.get("bodies", {}) + var groups: Dictionary = roster.get("groups", {}) + for n: Variant in groups.get(GROUP, []): + var b: Dictionary = all.get(str(n), {}) + var path := str(b.get("path", AP.CHAR_BODIES_DIR + str(n) + ".glb")) + if b.is_empty() or not ResourceLoader.exists(path): + continue + var kd := {"name": str(n), "gender": str(b.get("gender", "m")), "path": path} + kd["rest"] = float(b.get("rest_height_m", 1.75)) + kd["hip"] = float(b.get("hip_m", 0.9)) + kinds.append(kd) + if typeof(roster.get("height")) == TYPE_DICTIONARY: + _height = roster["height"] + for g: String in ["m", "f"]: + var lp := AP.CHAR_LIB_M if g == "m" else AP.CHAR_LIB_F + var lib: AnimationLibrary = ( + load(lp) as AnimationLibrary if ResourceLoader.exists(lp) else null + ) + if lib == null: + why = "clip library %s missing" % g + return false + _libs[g] = lib + var per: Dictionary = {} + for clip: StringName in lib.get_animation_list(): + var a := lib.get_animation(clip) + var ref_hip := maxf(float(a.get_meta("speed_ref_hip_m", 1.0)), 0.01) + per[clip] = float(a.get_meta("speed_mps", 0.0)) / ref_hip + _clip_speed[g] = per + ok = not kinds.is_empty() + why = "" if ok else "no trades body in the roster" + return ok + + +## Starts loading every trades body on a background thread. +func start() -> void: + for kd: Dictionary in kinds: + _request(str(kd["path"])) + + +## Waits for the loads still in flight (quitting with one running aborts it noisily). +func finish_loads() -> void: + for path: String in _loading.keys(): + _loaded[path] = ResourceLoader.load_threaded_get(path) as PackedScene + _loading.clear() + + +## A body of kinds[k] (any k, wrapped) under parent, its stature picked by jitter (0..1), or null +## while its scene still loads. Reuses a released body of the same kind first. +func make(k: int, jitter: float, parent: Node3D) -> Body: + var kk := posmod(k, kinds.size()) if ok else -1 + var spare: Array = _pool.get(kk, []) + if not spare.is_empty(): + var again: Body = spare.pop_back() + parent.add_child(again.root) + again.root.visible = true + again.clip = &"" + return again + if kk < 0: + return _stand_in(parent) + var kd: Dictionary = kinds[kk] + var path := str(kd["path"]) + var ps := _scene(path) + if ps == null: + return null if _loading.has(path) else _stand_in(parent) + var b := Body.new() + b.kind = kk + b.gender = str(kd["gender"]) + var sc := WS.M * stature(b.gender, jitter) / maxf(float(kd["rest"]), 0.5) + b.stride = float(kd["hip"]) * sc + b.root = Node3D.new() + b.root.name = "Worker_%s_%d" % [kd["name"], made] + b.root.scale = Vector3.ONE * sc + var scene := ps.instantiate() as Node3D + b.root.add_child(scene) + parent.add_child(b.root) + made += 1 + b.skel = scene.get_node_or_null("%GeneralSkeleton") as Skeleton3D + for g: Node in scene.find_children("*", "GeometryInstance3D", true, false): + (g as GeometryInstance3D).visibility_range_end = WS.VIS_PERSON + if b.skel == null: + return b + var anim := AnimationPlayer.new() + anim.name = "Anim" + scene.add_child(anim) + anim.add_animation_library("", _libs[b.gender]) + anim.root_motion_track = NodePath(AP.CHAR_ROOT_MOTION_TRACK) + b.ap = anim + return b + + +## Takes b off the street for a later make() of the same kind. +func release(b: Body) -> void: + if b == null or b.root == null or not is_instance_valid(b.root): + return + var p := b.root.get_parent() + if p != null: + p.remove_child(b.root) + if b.crate != null: + b.crate.visible = false + if b.ap != null: + b.ap.stop() + b.ap.active = true + if not _pool.has(b.kind): + _pool[b.kind] = [] + (_pool[b.kind] as Array).append(b) + + +## Frees the released bodies (teardown). +func clear() -> void: + for spare: Array in _pool.values(): + for b: Body in spare: + if is_instance_valid(b.root): + b.root.free() + _pool.clear() + + +## Stature (m) for a gender and a 0..1 jitter value, from the roster's height targets. +func stature(g: String, jitter: float) -> float: + var span: Array = _height.get("jitter", [0.97, 1.03]) + var base := float(_height.get("target_m", 1.75)) + if g == "f": + base *= float(_height.get("female_factor", 0.95)) + return base * lerpf(float(span[0]), float(span[1]), clampf(jitter, 0.0, 1.0)) + + +## World units per second that b covers playing clip (0 for clips that stand still). +func speed(b: Body, clip: StringName) -> float: + if b == null or b.ap == null: + return 0.6 * WS.M if clip == &"carry" or clip == &"walk" else 0.0 + var per: Dictionary = _clip_speed.get(b.gender, {}) + return float(per.get(clip, 0.0)) * b.stride + + +## Plays clip on b. Asking for the clip it already plays restarts it (a one-shot that ended). +static func play(b: Body, clip: StringName, blend: float = 0.25) -> void: + if b == null or b.ap == null or not b.ap.has_animation(clip): + return + var again := b.clip == clip + b.clip = clip + b.ap.play(clip, 0.0 if again else blend) + if again: + b.ap.seek(0.0, true) + + +## True while b's current clip still runs (loops always do). +static func playing(b: Body) -> bool: + return b == null or b.ap == null or b.ap.is_playing() + + +## Gives b a crate held in both hands of its carry pose (hidden: the rig shows it while carrying). +func add_crate(b: Body) -> void: + if b == null or b.skel == null or b.crate != null: + return + if not _crate_fit.has(b.gender): + _crate_fit[b.gender] = _fit_crate(b) + var fit: Array = _crate_fit[b.gender] + var att := BoneAttachment3D.new() + att.name = "Crate" + att.bone_name = "UpperChest" + b.skel.add_child(att) + var box := MeshInstance3D.new() + box.mesh = fit[1] + box.transform = fit[0] + att.add_child(box) + att.visible = false + b.crate = att + + +## The crate between the hands at the start of b's carry clip, in the UpperChest bone's frame, and +## a box mesh as wide as the hands are apart. +func _fit_crate(b: Body) -> Array: + if _crate_mat == null: + _crate_mat = StandardMaterial3D.new() + _crate_mat.albedo_color = CRATE_COLOR + _crate_mat.roughness = 0.9 + var mesh := BoxMesh.new() + mesh.material = _crate_mat + mesh.size = Vector3(0.4, CRATE_HEIGHT, CRATE_DEPTH) + var sk := b.skel + var chest := sk.find_bone("UpperChest") + var left := sk.find_bone("LeftHand") + var right := sk.find_bone("RightHand") + if chest < 0 or left < 0 or right < 0 or b.ap == null or not b.ap.has_animation(&"carry"): + return [Transform3D(Basis(), Vector3(0.0, -0.12, 0.3)), mesh] + b.ap.play(&"carry") + b.ap.seek(0.0, true) + var hand_l := _bone_in_skeleton(sk, left).origin + var hand_r := _bone_in_skeleton(sk, right).origin + var side := hand_l - hand_r + mesh.size.x = clampf(side.length() - 0.05, 0.28, 0.5) + var x_axis := side.normalized() + var z_axis := x_axis.cross(Vector3.UP).normalized() + z_axis = -z_axis if z_axis.z < 0.0 else z_axis + var held := Transform3D(Basis(x_axis, z_axis.cross(x_axis), z_axis), (hand_l + hand_r) * 0.5) + b.ap.stop() + return [_bone_in_skeleton(sk, chest).affine_inverse() * held, mesh] + + +## Bone pose in skeleton space, composed from the local poses (the global cache can lag a seek). +static func _bone_in_skeleton(sk: Skeleton3D, bone: int) -> Transform3D: + var out := Transform3D.IDENTITY + var at := bone + while at >= 0: + out = sk.get_bone_pose(at) * out + at = sk.get_bone_parent(at) + return out + + +func _request(path: String) -> void: + if _loaded.has(path) or _loading.has(path): + return + if ResourceLoader.load_threaded_request(path, "PackedScene") == OK: + _loading[path] = true + else: + _loaded[path] = null + + +## The scene at path once it is in; null while it loads (unless blocking) or when it failed. +func _scene(path: String) -> PackedScene: + if _loaded.has(path): + return _loaded[path] + _request(path) + if not _loading.has(path): + return null + var busy := ( + ResourceLoader.load_threaded_get_status(path) == ResourceLoader.THREAD_LOAD_IN_PROGRESS + ) + if busy and not blocking: + return null + _loading.erase(path) + _loaded[path] = ResourceLoader.load_threaded_get(path) as PackedScene + return _loaded[path] + + +func _stand_in(parent: Node3D) -> Body: + var b := Body.new() + b.root = Node3D.new() + b.root.name = "WorkerStandIn_%d" % made + made += 1 + var cap := CapsuleMesh.new() + cap.radius = 0.25 * WS.M + cap.height = 1.75 * WS.M + var m := StandardMaterial3D.new() + m.albedo_color = STAND_IN_COLOR + cap.material = m + var mi := MeshInstance3D.new() + mi.mesh = cap + mi.position.y = cap.height * 0.5 + mi.visibility_range_end = WS.VIS_PERSON + b.root.add_child(mi) + parent.add_child(b.root) + return b diff --git a/scripts/fp/crews/crew_bodies.gd.uid b/scripts/fp/crews/crew_bodies.gd.uid new file mode 100644 index 00000000..80f48f7c --- /dev/null +++ b/scripts/fp/crews/crew_bodies.gd.uid @@ -0,0 +1 @@ +uid://bako7t33mv87k diff --git a/scripts/fp/crews/crew_card.gd b/scripts/fp/crews/crew_card.gd new file mode 100644 index 00000000..cb0f6368 --- /dev/null +++ b/scripts/fp/crews/crew_card.gd @@ -0,0 +1,218 @@ +extends Control +## The player's crews on the HUD (feature "crews"), top left under the top bar: how many crews are +## free, a row per order a crew is on (the crew, the order, the time left, what the crew does now, +## a progress bar and how far away the site is) and the orders still waiting for a crew. The +## feature fills it through show_jobs(); nothing here reads the sim. + +const S := preload("res://scripts/fp/ui/hud_style.gd") + +const WIDTH := 300.0 +## Text width inside a row: WIDTH less the 10 px side margins and the 3 px stripe. +const INNER := WIDTH - 23.0 +const SAFE := 24.0 +## Under the compass strip (it ends at y 106 in the 1600x900 base), level with the AI feed. +const TOP := 118.0 +const BAR_H := 4.0 +## Waiting orders listed by name; the rest are counted. +const MAX_WAITING := 3 +const ROW_BG := Color(0.03, 0.04, 0.07, 0.7) +const WAIT_BG := Color(0.03, 0.04, 0.07, 0.5) +const BAR_BG := Color(1, 1, 1, 0.14) + +var _col: VBoxContainer +var _free: Label +var _list: VBoxContainer +var _wait_box: PanelContainer +var _waiting: Label +var _hint: HBoxContainer +## order id -> {"panel", "crew", "label", "left", "detail", "fill"} +var _rows: Dictionary = {} + + +func setup() -> void: + name = "CrewCard" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _col = VBoxContainer.new() + _col.name = "Column" + _col.mouse_filter = Control.MOUSE_FILTER_IGNORE + _col.add_theme_constant_override("separation", 4) + _col.offset_left = SAFE + _col.offset_right = SAFE + WIDTH + _col.offset_top = TOP + add_child(_col) + var head := _hbox(8) + head.name = "Header" + var title := S.caps_label("Your crews", 12, S.AMBER, 2) + title.name = "Title" + head.add_child(title) + _free = S.label("", 12, S.TEXT_DIM, S.font(), 2) + _free.name = "Free" + _free.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _free.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + head.add_child(_free) + _col.add_child(head) + _list = VBoxContainer.new() + _list.name = "Rows" + _list.mouse_filter = Control.MOUSE_FILTER_IGNORE + _list.add_theme_constant_override("separation", 4) + _col.add_child(_list) + _wait_box = PanelContainer.new() + _wait_box.name = "Waiting" + _wait_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + _wait_box.add_theme_stylebox_override("panel", S.box(WAIT_BG, S.RADIUS, 10, 4)) + _waiting = S.plain("", 12, S.TEXT_DIM, S.font()) + _waiting.name = "Text" + _waiting.custom_minimum_size = Vector2(INNER, 0) + _wait_box.add_child(_waiting) + _wait_box.visible = false + _col.add_child(_wait_box) + _hint = _hbox(6) + _hint.name = "Hint" + var key := S.key_cap("B", 10) + key.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _hint.add_child(key) + _hint.add_child(S.caps_label("Phone: order a build", 10, S.TEXT_DIM, 2)) + _col.add_child(_hint) + + +## rows, the orders a crew is on and then the waiting ones: {"id", "crew" (index, -1 while it +## waits), "label", "stage" (what the crew does now; "" while it waits), "step" ("3 of 5"), +## "fraction" (0..1), "left" (time left, or until a crew is free), "where" ("240 m NE")}. free: +## crews with no order, of crews. +func show_jobs(rows: Array[Dictionary], free: int, crews: int) -> void: + _free.text = "%d of %d free" % [free, crews] if free > 0 else "all busy" + var keep := {} + var waiting := PackedStringArray() + for r: Dictionary in rows: + if int(r["crew"]) < 0: + waiting.append("%s · starts in %s" % [r["label"], r["left"]]) + continue + var id := int(r["id"]) + keep[id] = true + if not _rows.has(id): + _rows[id] = _make_row() + _list.add_child(_rows[id]["panel"]) + _fill_row(_rows[id], r) + _list.move_child(_rows[id]["panel"], keep.size() - 1) + for id: int in _rows.keys(): + if not keep.has(id): + var p: Control = _rows[id]["panel"] + _list.remove_child(p) + p.queue_free() + _rows.erase(id) + _show_waiting(waiting) + _hint.visible = free > 0 + + +## Orders on screen with a crew. +func row_count() -> int: + return _rows.size() + + +## The text of order id's row ("Crew 1 · Police Station · 2:05 · pouring concrete · …"), "" when +## no crew is on it. +func row_text(id: int) -> String: + if not _rows.has(id): + return "" + var r: Dictionary = _rows[id] + var parts := PackedStringArray() + for k: String in ["crew", "label", "left", "detail"]: + parts.append((r[k] as Label).text) + return " · ".join(parts) + + +## The progress bar's share of order id's row (0..1), -1 when no crew is on it. +func row_fraction(id: int) -> float: + return float((_rows[id]["fill"] as Control).anchor_right) if _rows.has(id) else -1.0 + + +## The waiting orders' lines ("" when none waits). +func waiting_text() -> String: + return _waiting.text if _wait_box.visible else "" + + +## "1 of 2 free" or "all busy". +func free_text() -> String: + return _free.text + + +func _show_waiting(lines: PackedStringArray) -> void: + var shown := lines.slice(0, MAX_WAITING) + if lines.size() > MAX_WAITING: + shown.append("and %d more waiting" % (lines.size() - MAX_WAITING)) + var t := "\n".join(shown) + if not t.is_empty(): + t = "Waiting: " + t + _waiting.text = t + _wait_box.visible = not t.is_empty() + + +func _make_row() -> Dictionary: + var p := PanelContainer.new() + p.name = "Order" + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + p.add_theme_stylebox_override("panel", S.stripe(S.box(ROW_BG, S.RADIUS, 10, 6), S.AMBER, 3)) + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", 3) + p.add_child(v) + var top := _hbox(8) + top.name = "Top" + v.add_child(top) + var crew := S.caps_label("", 12, S.AMBER, 0) + crew.name = "Crew" + crew.size_flags_vertical = Control.SIZE_SHRINK_CENTER + top.add_child(crew) + var label := S.plain("", 15, S.WHITE, S.bold()) + label.name = "Label" + label.clip_text = true + label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(label) + var left := S.plain("", 15, S.TEXT, S.bold()) + left.name = "Left" + top.add_child(left) + var detail := S.plain("", 12, S.TEXT_DIM, S.font()) + detail.name = "Detail" + detail.clip_text = true + detail.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + detail.custom_minimum_size = Vector2(INNER, 0) + v.add_child(detail) + var bar := ColorRect.new() + bar.name = "Bar" + bar.color = BAR_BG + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.custom_minimum_size = Vector2(INNER, BAR_H) + v.add_child(bar) + var fill := ColorRect.new() + fill.name = "Fill" + fill.color = S.AMBER + fill.mouse_filter = Control.MOUSE_FILTER_IGNORE + fill.anchor_bottom = 1.0 + bar.add_child(fill) + return {"panel": p, "crew": crew, "label": label, "left": left, "detail": detail, "fill": fill} + + +func _fill_row(row: Dictionary, r: Dictionary) -> void: + _set_text(row["crew"], "Crew %d" % (int(r["crew"]) + 1)) + _set_text(row["label"], str(r["label"])) + _set_text(row["left"], str(r["left"])) + var parts := PackedStringArray() + for k: String in ["stage", "step", "where"]: + if not str(r.get(k, "")).is_empty(): + parts.append(str(r[k])) + _set_text(row["detail"], " · ".join(parts)) + (row["fill"] as Control).anchor_right = clampf(float(r.get("fraction", 0.0)), 0.0, 1.0) + + +static func _set_text(l: Label, t: String) -> void: + if l.text != t: + l.text = t + + +static func _hbox(sep: int) -> HBoxContainer: + var h := HBoxContainer.new() + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + h.add_theme_constant_override("separation", sep) + return h diff --git a/scripts/fp/crews/crew_card.gd.uid b/scripts/fp/crews/crew_card.gd.uid new file mode 100644 index 00000000..a2d33f07 --- /dev/null +++ b/scripts/fp/crews/crew_card.gd.uid @@ -0,0 +1 @@ +uid://yqxtv0ukgi85 diff --git a/scripts/fp/crews/crew_grid.gd b/scripts/fp/crews/crew_grid.gd new file mode 100644 index 00000000..6a52b827 --- /dev/null +++ b/scripts/fp/crews/crew_grid.gd @@ -0,0 +1,475 @@ +extends RefCounted +## The places a crew can stand and walk on one construction site (feature "crews"). A grid of +## cells CELL apart covers the tile and the scaffold round it. At a cell a worker stands on the +## ground inside the hoarding or on one of the site's decks (ConstructionKit.deck: the slab as +## poured, the frame's floors, the scaffold's boards; on a deck narrower than a cell, on its middle +## line), where the collision boxes that box the deck whole (the slab under it, the frame or the +## scaffold round it) are not in the way. A node is a cell and a height where a worker fits: clear +## of every other box and cylinder near the tile (CollisionWorld), of the deck's posts and of the +## decks over it. Nodes link to their neighbours up to CLIMB higher or lower, and straight across +## up to HOP - 1 cells of something low enough to climb (a berm, the formwork), by points on top +## of it. path() is the way between two nodes. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +## HALF cells either side of the tile centre, CELL apart: out to 0.52, past the scaffold's boards +## round the widest footprint (0.9: out to 0.512). A worker stands on the ground only up to +## GROUND_HALF cells out (0.44), inside the hoarding at 0.47. Neighbouring cells' worker circles +## overlap, so no part of a site fits between them. +const CELL := 0.04 +const HALF := 13 +const GROUND_HALF := 11 +const SIDE := HALF * 2 + 1 +## A worker's footprint radius and height. +const RADIUS := 0.35 * WS.M +const HEIGHT := 1.8 * WS.M +## A worker steps or climbs this much up or down (1 m: onto a slab, over a berm or the +## formwork), across at most HOP - 1 cells. +const CLIMB := 1.0 * WS.M +const HOP := 3 +const EPS := 0.0005 +## Two nodes of one cell are at least this far apart in height. +const LEVEL_GAP := 0.002 +## A box this much short of a deck's edges still boxes it whole. +const SLACK := 0.002 +## The key of a link's climb points in _via: n * VIA + o. +const VIA := 1 << 16 +## The eight steps from a cell, straight ones first. +const STEPS: Array[Vector2i] = [ + Vector2i(1, 0), + Vector2i(-1, 0), + Vector2i(0, 1), + Vector2i(0, -1), + Vector2i(1, 1), + Vector2i(1, -1), + Vector2i(-1, 1), + Vector2i(-1, -1), +] + +## The tile centre at the ground datum. +var c: Vector3 = Vector3.ZERO +var boxes: Array[AABB] = [] +## Cylinders: (x, z, radius, top). +var cyls: Array[Vector4] = [] +## CityView.site_decks(): {"rect", "y", "t", "posts"}. +var decks: Array[Dictionary] = [] +## Per deck: {box index: true} for the boxes that box it whole. +var shells: Array[Dictionary] = [] +## Per cell (row by row): indices into boxes, cyls and decks near it, and its nodes. +var cell_boxes: Array[Array] = [] +var cell_cyls: Array[Array] = [] +var cell_decks: Array[Array] = [] +var at: Array[Array] = [] +## Per node: the feet, the cell, the deck (-1: the ground) and the nodes it links to. +var pos: Array[Vector3] = [] +var cell := PackedInt32Array() +var deck := PackedInt32Array() +var links: Array[PackedInt32Array] = [] +## n * VIA + o -> the points (Array[Vector3]) to climb over on the way from node n to node o, for +## a link across something. +var _via: Dictionary = {} +## Each box's footprint grown by a worker's radius; each deck's rect, top, thickness and posts +## (grown by a worker's radius). +var _foot: Array[Rect2] = [] +var _rect: Array[Rect2] = [] +var _top := PackedFloat64Array() +var _thick := PackedFloat64Array() +var _posts: Array[Array] = [] +## Node -> the parents of every node reached from it (tree()). +var _trees: Dictionary = {} + + +## Lays the grid over tile i as drawn now. +func setup(ctx: FpContext, i: int) -> void: + c = Vector3(D.tx(i), WS.GROUND_Y, D.ty(i)) + if ctx.collision != null: + _gather(ctx.collision, i) + if ctx.view != null: + decks = ctx.view.site_decks(i) + _prepare() + _bucket() + for k in range(SIDE * SIDE): + _add_nodes(ctx.collision, k) + for n in range(pos.size()): + _link(n) + + +## The node nearest p within two cells and up to dy above or below it (a height nearer p's +## counts for more); -1 when none. +func near(p: Vector3, dy := INF) -> int: + var gx := roundi((p.x - c.x) / CELL) + HALF + var gz := roundi((p.z - c.z) / CELL) + HALF + var best := -1 + var best_d := INF + for dz in range(-2, 3): + for dx in range(-2, 3): + var k := _cell_at(gx + dx, gz + dz) + if k < 0: + continue + for n: int in at[k]: + var h := absf(pos[n].y - p.y) + var d := _flat(pos[n], p) + h * 4.0 + if h <= dy and d < best_d: + best = n + best_d = d + return best + + +## True when p is where a node is: a worker fits there. +func stands(p: Vector3) -> bool: + var n := near(p) + return n >= 0 and pos[n].distance_to(p) < LEVEL_GAP + + +## The nodes ({node: true}) a worker at one of pts, at about its height, would bump into: closer +## than two worker radii across. +func taken_by(pts: Array[Vector3]) -> Dictionary: + var out := {} + for p: Vector3 in pts: + var gx := roundi((p.x - c.x) / CELL) + HALF + var gz := roundi((p.z - c.z) / CELL) + HALF + for dz in range(-1, 2): + for dx in range(-1, 2): + var k := _cell_at(gx + dx, gz + dz) + if k < 0: + continue + for n: int in at[k]: + if _flat(pos[n], p) < RADIUS * 2.0 and absf(pos[n].y - p.y) < CLIMB: + out[n] = true + return out + + +## The parents of every node reached from node s (s is its own; -1 for one it does not reach), +## not through the nodes in skip ({node: true}). +func tree(s: int, skip: Dictionary = {}) -> PackedInt32Array: + if skip.is_empty() and _trees.has(s): + return _trees[s] + var parent := PackedInt32Array() + parent.resize(pos.size()) + parent.fill(-1) + parent[s] = s + var queue: Array[int] = [s] + var head := 0 + while head < queue.size(): + var n := queue[head] + head += 1 + for o: int in links[n]: + if parent[o] < 0 and not skip.has(o): + parent[o] = n + queue.append(o) + if skip.is_empty(): + _trees[s] = parent + return parent + + +## The way from node a to node b, not through the nodes in skip: the points to walk through after +## a, ending at b; [] when b is a or a does not reach it. +func path(a: int, b: int, skip: Dictionary = {}) -> Array[Vector3]: + var out: Array[Vector3] = [] + if a < 0 or b < 0 or a == b: + return out + var parent := tree(a, skip) + if parent[b] < 0: + return out + var chain: Array[int] = [] + var n := b + while n != a: + chain.append(n) + n = parent[n] + chain.reverse() + var prev := a + for m: int in chain: + out.append_array(_via.get(prev * VIA + m, [])) + out.append(pos[m]) + prev = m + return _straighten(out, pos[a]) + + +# ------------------------------------------------------------------ building + + +func _gather(col: CollisionWorld, i: int) -> void: + var r := (float(HALF) + 0.5) * CELL + RADIUS + var area := Rect2(c.x - r, c.z - r, r * 2.0, r * 2.0) + for dz in range(-1, 2): + for dx in range(-1, 2): + if not D.in_bounds(D.tx(i) + dx, D.ty(i) + dz): + continue + var j := D.idx(D.tx(i) + dx, D.ty(i) + dz) + for b: AABB in col.boxes(j): + if area.intersects(_xz(b)): + boxes.append(b) + var cy := col.cylinders(j) + for k in range(0, cy.size(), 4): + if area.grow(cy[k + 2]).has_point(Vector2(cy[k], cy[k + 1])): + cyls.append(Vector4(cy[k], cy[k + 1], cy[k + 2], cy[k + 3])) + + +## The boxes' footprints grown by a worker's radius, the decks' parts, and per deck the boxes that +## box it whole. +func _prepare() -> void: + for b: AABB in boxes: + _foot.append(_xz(b).grow(RADIUS)) + for d: Dictionary in decks: + var r: Rect2 = d["rect"] + _rect.append(r) + _top.append(float(d["y"])) + _thick.append(float(d["t"])) + var grown: Array[Rect2] = [] + for p: Rect2 in d["posts"]: + grown.append(p.grow(RADIUS)) + _posts.append(grown) + var inner := r.grow(-SLACK) + var whole := {} + if inner.size.x > 0.0 and inner.size.y > 0.0: + for bi in range(boxes.size()): + if _xz(boxes[bi]).encloses(inner) and boxes[bi].end.y >= float(d["y"]) - EPS: + whole[bi] = true + shells.append(whole) + + +## Sorts the boxes, cylinders and decks into the cells whose nodes they may be in the way of or +## under: a node is up to half a cell off its cell's centre (on a narrow deck). +func _bucket() -> void: + for k in range(SIDE * SIDE): + cell_boxes.append([]) + cell_cyls.append([]) + cell_decks.append([]) + at.append([]) + for bi in range(boxes.size()): + for k: int in _cells(_foot[bi].grow(CELL * 0.5)): + cell_boxes[k].append(bi) + for ci in range(cyls.size()): + var cy := cyls[ci] + var r := cy.z + RADIUS + CELL * 0.5 + for k: int in _cells(Rect2(cy.x - r, cy.y - r, r * 2.0, r * 2.0)): + cell_cyls[k].append(ci) + for d in range(decks.size()): + for k: int in _cells(_rect[d].grow(CELL * 0.5)): + cell_decks[k].append(d) + + +func _add_nodes(col: CollisionWorld, k: int) -> void: + var xz := _cell_xz(k) + if _inside(k): + var g := col.ground_height(Vector3(xz.x, c.y, xz.y)) if col != null else c.y + if not is_nan(g): + _try(k, Vector3(xz.x, g, xz.y), -1) + for d: int in cell_decks[k]: + var q := Vector2(_along(xz.x, _rect[d].position.x, _rect[d].size.x), NAN) + q.y = _along(xz.y, _rect[d].position.y, _rect[d].size.y) + if not is_nan(q.x) and not is_nan(q.y): + _try(k, Vector3(q.x, _top[d], q.y), d) + + +## True for the cells GROUND_HALF or fewer out from the middle, inside the hoarding. +func _inside(k: int) -> bool: + var gz := floori(float(k) / SIDE) + return absi(k - gz * SIDE - HALF) <= GROUND_HALF and absi(gz - HALF) <= GROUND_HALF + + +## Where a deck has its node in a cell, on one axis: v, the cell's centre, when it is on the +## deck's span (size long from lo); across a span shorter than a cell, the span's middle when that +## is in v's cell; NAN otherwise. +static func _along(v: float, lo: float, size: float) -> float: + if size >= CELL: + return v if v >= lo and v <= lo + size else NAN + var mid := lo + size * 0.5 + return mid if mid >= v - CELL * 0.5 and mid < v + CELL * 0.5 else NAN + + +func _try(k: int, p: Vector3, d: int) -> void: + for n: int in at[k]: + if absf(pos[n].y - p.y) < LEVEL_GAP: + return + if not _clear(k, Vector2(p.x, p.z), p.y, d, -1): + return + at[k].append(pos.size()) + pos.append(p) + cell.append(k) + deck.append(d) + links.append(PackedInt32Array()) + + +## True when a worker fits with its feet at xz and height y in cell k. The boxes that box deck a +## or b whole (-1: none) are left out and the posts of both are kept clear. +func _clear(k: int, xz: Vector2, y: float, a: int, b: int) -> bool: + for bi: int in cell_boxes[k]: + var bx := boxes[bi] + var level := bx.end.y > y + EPS and bx.position.y < y + HEIGHT + if level and _foot[bi].has_point(xz) and not _shell(bi, a, b): + return false + for ci: int in cell_cyls[k]: + var cy := cyls[ci] + if cy.w > y + EPS and Vector2(cy.x, cy.y).distance_to(xz) < cy.z + RADIUS: + return false + if _on_post(a, xz) or _on_post(b, xz): + return false + for od: int in cell_decks[k]: + var over := _top[od] > y + EPS and _top[od] - _thick[od] < y + HEIGHT + if over and od != a and od != b and _rect[od].has_point(xz): + return false + return true + + +func _shell(bi: int, a: int, b: int) -> bool: + return (a >= 0 and shells[a].has(bi)) or (b >= 0 and shells[b].has(bi)) + + +func _on_post(d: int, xz: Vector2) -> bool: + if d < 0: + return false + for r: Rect2 in _posts[d]: + if r.has_point(xz): + return true + return false + + +## Links node n to the nodes of the cells round it it can step to: up to CLIMB up or down, and +## on a diagonal only past two straight neighbours it could step to as well (no cutting a +## corner). Straight on, with nothing to step to, it climbs over what is in between (_hop). +func _link(n: int) -> void: + var gx := cell[n] % SIDE + var gz := floori(float(cell[n]) / SIDE) + var y := pos[n].y + var out := PackedInt32Array() + for s: Vector2i in STEPS: + var m := _cell_at(gx + s.x, gz + s.y) + var diagonal := s.x != 0 and s.y != 0 + if m < 0 or diagonal and not _corner(gx, gz, s, y): + continue + var linked := false + for o: int in at[m]: + if absf(pos[o].y - y) <= CLIMB: + out.append(o) + linked = true + if not linked and not diagonal: + var o := _hop(n, gx, gz, s) + if o >= 0: + out.append(o) + links[n] = out + + +## True when both straight neighbours on the way along diagonal s have a node a step from y. +func _corner(gx: int, gz: int, s: Vector2i, y: float) -> bool: + return _steps(_cell_at(gx + s.x, gz), y) and _steps(_cell_at(gx, gz + s.y), y) + + +## True when cell k has a node a step from height y. +func _steps(k: int, y: float) -> bool: + if k < 0: + return false + for o: int in at[k]: + if absf(pos[o].y - y) <= CLIMB: + return true + return false + + +## The first node straight along s from n, beyond 1 to HOP - 1 cells it cannot step to, that n +## climbs to over the top of what is in them when that is low enough (-1: none). +func _hop(n: int, gx: int, gz: int, s: Vector2i) -> int: + var between: Array[int] = [] + for step in range(1, HOP + 1): + var m := _cell_at(gx + s.x * step, gz + s.y * step) + if m < 0: + return -1 + if step > 1: + var o := _hop_to(n, m, between) + if o >= 0: + return o + between.append(m) + return -1 + + +func _hop_to(n: int, m: int, between: Array[int]) -> int: + for o: int in at[m]: + var h := _over(n, o, between) + if is_nan(h): + continue + var a := _cell_xz(between[0]) + var b := _cell_xz(between[-1]) + var pts: Array[Vector3] = [Vector3(a.x, h, a.y)] + if between.size() > 1: + pts.append(Vector3(b.x, h, b.y)) + _via[n * VIA + o] = pts + return o + return -1 + + +## The height a worker climbs to on the way from node n to node o across the cells between: the +## top of the highest box in them it can climb; NAN when something there is in the way. +func _over(n: int, o: int, between: Array[int]) -> float: + if absf(pos[n].y - pos[o].y) > CLIMB: + return NAN + var a := deck[n] + var b := deck[o] + var lo := maxf(pos[n].y, pos[o].y) + var h := lo + for m: int in between: + var xz := _cell_xz(m) + for bi: int in cell_boxes[m]: + var top := boxes[bi].end.y + if top > h and top <= lo + CLIMB and _foot[bi].has_point(xz) and not _shell(bi, a, b): + h = top + for m: int in between: + if not _clear(m, _cell_xz(m), h, a, b): + return NAN + return h + + +# ------------------------------------------------------------------ cells + + +func _cell_at(gx: int, gz: int) -> int: + if gx < 0 or gz < 0 or gx >= SIDE or gz >= SIDE: + return -1 + return gz * SIDE + gx + + +func _cell_xz(k: int) -> Vector2: + var gz := floori(float(k) / SIDE) + return Vector2(c.x + float(k - gz * SIDE - HALF) * CELL, c.z + float(gz - HALF) * CELL) + + +## The cells whose centres are in r. +func _cells(r: Rect2) -> Array[int]: + var out: Array[int] = [] + var x0 := c.x - float(HALF) * CELL + var z0 := c.z - float(HALF) * CELL + var ix0 := maxi(ceili((r.position.x - x0) / CELL), 0) + var ix1 := mini(floori((r.end.x - x0) / CELL), SIDE - 1) + var iz0 := maxi(ceili((r.position.y - z0) / CELL), 0) + var iz1 := mini(floori((r.end.y - z0) / CELL), SIDE - 1) + for iz in range(iz0, iz1 + 1): + for ix in range(ix0, ix1 + 1): + out.append(iz * SIDE + ix) + return out + + +## pts without the points a straight walk at one height passes through anyway. +static func _straighten(pts: Array[Vector3], from: Vector3) -> Array[Vector3]: + var out: Array[Vector3] = [] + var prev := from + for k in range(pts.size()): + var p := pts[k] + if k + 1 < pts.size() and _in_line(prev, p, pts[k + 1]): + continue + out.append(p) + prev = p + return out + + +static func _in_line(a: Vector3, b: Vector3, c2: Vector3) -> bool: + if absf(a.y - b.y) > EPS or absf(b.y - c2.y) > EPS: + return false + var u := Vector2(b.x - a.x, b.z - a.z) + var v := Vector2(c2.x - b.x, c2.z - b.z) + return absf(u.cross(v)) < 1e-7 and u.dot(v) > 0.0 + + +static func _xz(b: AABB) -> Rect2: + return Rect2(b.position.x, b.position.z, b.size.x, b.size.z) + + +static func _flat(a: Vector3, b: Vector3) -> float: + return Vector2(a.x - b.x, a.z - b.z).length() diff --git a/scripts/fp/crews/crew_grid.gd.uid b/scripts/fp/crews/crew_grid.gd.uid new file mode 100644 index 00000000..1a650fcf --- /dev/null +++ b/scripts/fp/crews/crew_grid.gd.uid @@ -0,0 +1 @@ +uid://bbj4fgoou3p5y diff --git a/scripts/fp/crews/crew_rig.gd b/scripts/fp/crews/crew_rig.gd new file mode 100644 index 00000000..c558ea71 --- /dev/null +++ b/scripts/fp/crews/crew_rig.gd @@ -0,0 +1,526 @@ +extends Node3D +## One crew at work on its site (feature "crews"): a foreman beside the gate keeping an eye on +## things, two builders at the spots nearest the middle and a hauler carrying crates from a pile +## inside the gate to a drop point by the builders, under a board that names the crew, the order, +## the stage and the time left. What the builders do follows the stage. A crew that takes an +## order while you play walks in through the gate. Every place and every way comes from the site's +## CrewGrid (CrewSpots), so nobody stands or walks in the hoarding, a machine or the frame, and a +## worker climbs onto a slab or over a berm instead of through it; place() runs again when the +## site is redrawn for a new stage, and a worker whose spot moved walks over when it can. + +const WS := preload("res://scripts/fp/world_scale.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") +const BODIES := preload("res://scripts/fp/crews/crew_bodies.gd") +const SPOTS := preload("res://scripts/fp/crews/crew_spots.gd") +## A crew, in order: the foreman takes the gate, the builders the work spots, the hauler the pile. +const ROLES: Array[String] = ["foreman", "builder", "builder", "hauler"] +## What the builders do in each stage; a looping clip is held HOLD seconds, a one-shot plays out. +const STAGE_CLIPS := { + "clearing": [&"push", &"pickup", &"crouch_idle"], + "excavation": [&"push", &"crouch_idle"], + "grading": [&"push", &"crouch_idle"], + "demolition": [&"push", &"interact"], + "hauling": [&"pickup", &"push"], + "foundation": [&"crouch_idle", &"interact"], + "paving": [&"crouch_idle", &"push"], + "piling": [&"interact", &"push"], + "landscaping": [&"crouch_idle", &"pickup", &"interact"], +} +const BUILD_CLIPS: Array[StringName] = [&"interact", &"crouch_idle", &"interact", &"push"] +const FOREMAN_CLIPS: Array[StringName] = [&"idle_look", &"fold_arms", &"phone", &"idle_look"] +## A hauler with no clear way from the pile to the work sorts the pile instead. +const SORT_CLIPS: Array[StringName] = [&"crouch_idle", &"pickup", &"idle_look"] +const HOLD := Vector2(3.0, 6.0) +## One-shot lengths for a worker whose body has not loaded yet. +const ONE_SHOT_S := {&"pickup": 0.83, &"interact": 2.0} +## A worker is at a waypoint within this distance (world units). +const ARRIVE := 0.1 * WS.M +## Seconds between workers stepping off as a crew arrives. +const STAGGER := 0.8 +## How quickly a worker turns toward where it goes or what it works on (1/s). +const TURN := 5.0 +const BOARD_UP := 3.4 * WS.M +const BOARD_COLOR := Color(1.0, 0.84, 0.32) +const DUST_COLOR := Color(0.8, 0.74, 0.64, 0.38) +## The crew, its board and its dust show within this distance of the player (bodies end at +## VIS_PERSON anyway). +const NEAR := WS.VIS_PERSON + 0.5 +## The interaction ray has to pass within PICK_R of a worker, up to PICK_H above its feet. +const PICK_R := 0.4 * WS.M +const PICK_H := 1.8 * WS.M +const CHEST := 1.3 * WS.M +## How long a worker stops to talk after E. +const TALK_S := 3.5 + +static var _dust_mat: StandardMaterial3D + + +## One worker: where it is and what it does. Its body (CrewBodies) appears once it has loaded. +class Worker: + extends RefCounted + var role: String = "builder" + var body: BODIES.Body = null + var kind: int = 0 + var jitter: float = 0.5 + var pos: Vector3 = Vector3(NAN, NAN, NAN) + var yaw: float = 0.0 + ## Where it works and what it faces there. + var spot: Vector3 = Vector3(NAN, NAN, NAN) + var face: Vector3 = Vector3(NAN, NAN, NAN) + ## Who it talks to after E. + var look: Vector3 = Vector3(NAN, NAN, NAN) + var path: Array[Vector3] = [] + ## Where the walk to path[0] set off from (its height eases to path[0]'s on the way). + var from: Vector3 = Vector3(NAN, NAN, NAN) + ## off (no room on the site), wait (to walk in), walk, work, take / put (a crate), talk. + var state: String = "work" + ## The state a talk goes back to. + var after: String = "work" + ## Seconds left in the current clip, and its whole length. + var t: float = 0.0 + var span: float = 0.0 + var step: int = 0 + var carrying: bool = false + var clip: StringName = &"idle" + + +var job_id: int = 0 +var crew: int = 0 +var tile: int = -1 +## CrewSpots.layout of the site ({} before place()). +var spots: Dictionary = {} +var workers: Array[Worker] = [] +var board: Label3D +var dust: CPUParticles3D +## The site's stage (a SITE_STAGES name), which picks what the builders do. +var stage: String = "" +var near: bool = true +var _bodies: BODIES +var _rng := RandomNumberGenerator.new() + + +## A crew of ROLES for job (a CitySim.crew_jobs entry) on tile i, its bodies from bodies. +func setup(bodies: BODIES, job: Dictionary, i: int) -> void: + _bodies = bodies + job_id = int(job["id"]) + crew = int(job["crew"]) + tile = i + name = "Crew%d_%d" % [crew + 1, job_id] + _rng.seed = hash([job_id, i]) + for k in range(ROLES.size()): + var w := Worker.new() + w.role = ROLES[k] + w.kind = k + crew + w.jitter = _rng.randf() + w.step = _rng.randi_range(0, 3) + workers.append(w) + board = _make_board() + add_child(board) + dust = _make_dust() + add_child(dust) + + +## Lays the crew out on the site as it is drawn now. With arrive the crew walks in from outside +## the gate; otherwise a worker walks to its new spot when it can get there, or is put there. +func place(ctx: FpContext, arrive: bool) -> void: + spots = SPOTS.layout(ctx, tile, ROLES.count("builder")) + var work: Array = spots["work"] + var builders := 0 + for k in range(workers.size()): + var w := workers[k] + _assign(w, _spot_for(w.role, work, builders), k if arrive else -1) + if w.role == "builder": + builders += 1 + var gate: Vector3 = spots["gate"] + board.position = (gate if SPOTS.found(gate) else spots["middle"]) + Vector3.UP * BOARD_UP + dust.position = work[0] if not work.is_empty() else spots["middle"] + + +## Moves the crew on by dt seconds; it shows only near player_pos. +func step(dt: float, player_pos: Vector3) -> void: + var middle: Vector3 = spots.get("middle", SPOTS.NONE) + _set_near(SPOTS.found(middle) and _flat(player_pos, middle) < NEAR) + var busy := false + for w: Worker in workers: + if w.state == "off": + continue + match w.state: + "wait": + w.t -= dt + if w.t <= 0.0: + w.state = "walk" + "walk": + _walk(w, dt) + "work": + _work(w, dt) + busy = busy or w.role == "builder" + "take", "put": + _haul(w, dt) + "talk": + _talk(w, dt) + if near: + _show(w) + dust.emitting = near and busy + + +## The board's two lines: the crew and the order, then the stage and the time left. +func set_board(title: String, line: String) -> void: + board.text = "%s\n%s" % [title, line] + + +## Worker k stops what it is doing to talk to the player at `at` for TALK_S. False while it walks. +func greet(k: int, at: Vector3) -> bool: + if k < 0 or k >= workers.size(): + return false + var w := workers[k] + if not w.state in ["work", "take", "put", "talk"]: + return false + if w.state != "talk": + w.after = w.state + w.state = "talk" + w.look = at + w.clip = &"talk" + BODIES.play(w.body, &"talk") + w.t = TALK_S + return true + + +## The worker the ray from origin along dir passes within PICK_R of, nearest first, up to +## max_dist: {"k" (index in workers), "t" (distance along the ray), "pos" (its chest)}, or {}. +func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary: + var best := {} + if not near: + return best + var end := origin + dir * max_dist + for k in range(workers.size()): + var w := workers[k] + if w.body == null or w.state == "off" or w.state == "wait": + continue + var top := w.pos + Vector3.UP * PICK_H + var seg := Geometry3D.get_closest_points_between_segments(origin, end, w.pos, top) + if seg[0].distance_to(seg[1]) > PICK_R: + continue + var t := origin.distance_to(seg[0]) + if best.is_empty() or t < float(best["t"]): + best = {"k": k, "t": t, "pos": w.pos + Vector3.UP * CHEST} + return best + + +## How many workers are at work on the site with a body (tests and the self test). +func working() -> int: + var n := 0 + for w: Worker in workers: + n += 1 if w.body != null and w.state != "off" and w.state != "wait" else 0 + return n + + +## Gives every body back to the pool (before the rig is freed). +func release() -> void: + for w: Worker in workers: + if w.body != null: + _bodies.release(w.body) + w.body = null + + +# ------------------------------------------------------------------ layout + + +func _spot_for(role: String, work: Array, builders: int) -> Vector3: + match role: + "foreman": + return spots["gate"] + "hauler": + return spots["pile"] + return work[builders] if builders < work.size() else SPOTS.NONE + + +## Gives w its spot: it walks in from outside the gate (order: its place in the line, -1 for no +## walk-in), walks over from where it stands, or appears there. +func _assign(w: Worker, spot: Vector3, order: int) -> void: + var drop: Vector3 = spots["drop"] + w.spot = spot + w.face = drop if w.role == "hauler" and SPOTS.found(drop) else spots["middle"] + if not SPOTS.found(spot): + _off(w) + return + var start: Vector3 = spots["out"] + if order >= 0 and SPOTS.found(start): + w.path = _arrival(start, spot) + if not w.path.is_empty(): + w.pos = start + w.from = start + w.state = "wait" + w.t = STAGGER * float(order) + w.yaw = _heading(start, w.path[0], w.yaw) + return + if not SPOTS.found(w.pos) or w.state == "off": + _put(w, spot) + return + var to := drop if w.role == "hauler" and w.carrying and SPOTS.found(drop) else spot + if w.pos.distance_to(to) < ARRIVE and w.state != "walk" and w.state != "wait": + return + w.path = SPOTS.route(spots, w.pos, to) + if w.path.is_empty(): + _put(w, spot) + else: + w.from = w.pos + w.state = "walk" + + +## The way in from start outside the gate: through the gate to the door, then over the site to +## spot; [] when the door does not reach spot. +func _arrival(start: Vector3, spot: Vector3) -> Array[Vector3]: + var out: Array[Vector3] = [] + var door: Vector3 = spots.get("door", SPOTS.NONE) + if not SPOTS.found(door) or start.distance_to(door) < ARRIVE: + return out + var rest := SPOTS.route(spots, door, spot) + if rest.is_empty() and door.distance_to(spot) > ARRIVE: + return out + out.append(door) + out.append_array(rest) + return out + + +## No room for w on the site at this stage: it sits it out. +func _off(w: Worker) -> void: + if w.body != null: + _bodies.release(w.body) + w.body = null + w.state = "off" + w.pos = SPOTS.NONE + w.path.clear() + w.carrying = false + + +func _put(w: Worker, p: Vector3) -> void: + w.pos = p + if SPOTS.found(w.face): + w.yaw = _heading(p, w.face, w.yaw) + _settle(w) + + +# ------------------------------------------------------------------ work + + +## w is where it was going: the hauler takes or sets down a crate, everyone else works. +func _settle(w: Worker) -> void: + w.path.clear() + if w.role == "hauler" and SPOTS.found(spots.get("drop", SPOTS.NONE)): + _begin(w, "put" if w.carrying else "take") + else: + _begin(w, "work") + + +func _begin(w: Worker, st: String) -> void: + w.state = st + w.t = 0.0 + if st == "take" or st == "put": + _play(w, &"pickup") + + +func _walk(w: Worker, dt: float) -> void: + if w.path.is_empty(): + _settle(w) + return + var clip := &"carry" if w.carrying else &"walk" + if w.clip != clip: + w.clip = clip + BODIES.play(w.body, clip) + var to: Vector3 = w.path[0] + var d := Vector3(to.x - w.pos.x, 0.0, to.z - w.pos.z) + var dist := d.length() + var move := _bodies.speed(w.body, clip) * dt + if dist <= maxf(move, ARRIVE): + w.pos = to + w.from = to + w.path.pop_front() + if w.path.is_empty(): + _settle(w) + return + w.pos += d / dist * move + w.pos.y = _ease_y(w, to, dist - move) + w.yaw = lerp_angle(w.yaw, atan2(d.x, d.z), minf(1.0, dt * TURN)) + + +## w's height on the way from w.from to `to` with `left` still to go: up or down a step, a slab +## or a berm as it goes. +static func _ease_y(w: Worker, to: Vector3, left: float) -> float: + var span := _flat(w.from, to) if SPOTS.found(w.from) else 0.0 + if span < 1e-6: + return to.y + return lerpf(w.from.y, to.y, clampf(1.0 - left / span, 0.0, 1.0)) + + +func _work(w: Worker, dt: float) -> void: + _turn_to(w, w.face, dt) + w.t -= dt + if w.t > 0.0: + return + var list := _clips(w) + _play(w, list[w.step % list.size()]) + w.step += 1 + + +func _clips(w: Worker) -> Array: + match w.role: + "foreman": + return FOREMAN_CLIPS + "hauler": + return SORT_CLIPS + return STAGE_CLIPS.get(stage, BUILD_CLIPS) + + +## Picking a crate up at the pile (take) or setting it down by the work (put): the crate comes or +## goes half way through, then the hauler sets off the other way. +func _haul(w: Worker, dt: float) -> void: + _turn_to(w, w.face if w.state == "take" else spots["middle"], dt) + w.t -= dt + if w.t <= w.span * 0.5: + w.carrying = w.state == "take" + if w.t > 0.0: + return + w.path.assign(spots["haul"] if w.carrying else spots["back"]) + if w.path.is_empty(): + w.path.append(spots["drop"] if w.carrying else spots["pile"]) + w.from = w.pos + w.state = "walk" + + +func _talk(w: Worker, dt: float) -> void: + _turn_to(w, w.look, dt) + w.t -= dt + if w.t <= 0.0: + _begin(w, w.after) + + +## Plays clip on w and sets how long w keeps at it: a one-shot to its end, a loop HOLD seconds. +func _play(w: Worker, clip: StringName) -> void: + w.clip = clip + BODIES.play(w.body, clip) + var ap := w.body.ap if w.body != null else null + var hold := _rng.randf_range(HOLD.x, HOLD.y) + if ap != null and ap.has_animation(clip): + var a := ap.get_animation(clip) + w.t = a.length if a.loop_mode == Animation.LOOP_NONE else hold + else: + w.t = float(ONE_SHOT_S.get(clip, hold)) + w.span = w.t + + +func _turn_to(w: Worker, p: Vector3, dt: float) -> void: + if not SPOTS.found(p) or _flat(w.pos, p) < ARRIVE: + return + w.yaw = lerp_angle(w.yaw, _heading(w.pos, p, w.yaw), minf(1.0, dt * TURN)) + + +# ------------------------------------------------------------------ bodies + + +## Puts w's body where w is (making it once its scene has loaded), with the crate while carrying. +func _show(w: Worker) -> void: + if w.body == null: + w.body = _bodies.make(w.kind, w.jitter, self) + if w.body == null: + return + if w.role == "hauler": + _bodies.add_crate(w.body) + BODIES.play(w.body, w.clip, 0.0) + var r := w.body.root + r.visible = w.state != "wait" + r.position = w.pos + r.rotation.y = w.yaw + if w.body.crate != null: + w.body.crate.visible = w.carrying + + +## Out of sight the crew stops drawing and animating (it keeps working). +func _set_near(on: bool) -> void: + if on == near: + return + near = on + visible = on + for w: Worker in workers: + if w.body != null and w.body.ap != null: + w.body.ap.active = on + + +static func _heading(a: Vector3, b: Vector3, fallback: float) -> float: + var d := Vector2(b.x - a.x, b.z - a.z) + return fallback if d.length_squared() < 1e-12 else atan2(d.x, d.y) + + +static func _flat(a: Vector3, b: Vector3) -> float: + return Vector2(a.x - b.x, a.z - b.z).length() + + +# ------------------------------------------------------------------ board and dust + + +func _make_board() -> Label3D: + var l := Label3D.new() + l.name = "Board" + l.billboard = BaseMaterial3D.BILLBOARD_FIXED_Y + l.font = S.bold() + l.font_size = 40 + l.pixel_size = 0.0003 + l.outline_size = 12 + l.outline_modulate = Color(0.02, 0.03, 0.05, 0.9) + l.modulate = BOARD_COLOR + l.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM + l.visibility_range_end = NEAR + l.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return l + + +## Soft puffs of dust drifting up from the work while the builders are at it. +func _make_dust() -> CPUParticles3D: + var p := CPUParticles3D.new() + p.name = "Dust" + p.emitting = false + p.amount = 10 + p.lifetime = 2.4 + p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE + p.emission_sphere_radius = 0.6 * WS.M + p.direction = Vector3.UP + p.spread = 70.0 + p.gravity = Vector3(0.0, 0.04 * WS.M, 0.0) + p.initial_velocity_min = 0.1 * WS.M + p.initial_velocity_max = 0.35 * WS.M + p.scale_amount_min = 0.6 + p.scale_amount_max = 1.6 + var fade := Gradient.new() + fade.set_color(0, Color(DUST_COLOR, 0.0)) + fade.set_color(1, Color(DUST_COLOR, 0.0)) + fade.add_point(0.3, DUST_COLOR) + p.color_ramp = fade + var quad := QuadMesh.new() + quad.size = Vector2.ONE * (0.7 * WS.M) + quad.material = _dust_material() + p.mesh = quad + p.visibility_range_end = NEAR + p.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return p + + +## A round, soft-edged billboard tinted by each particle's colour (shared by every crew). +static func _dust_material() -> StandardMaterial3D: + if _dust_mat != null: + return _dust_mat + var soft := Gradient.new() + soft.set_color(0, Color(1, 1, 1, 1)) + soft.set_color(1, Color(1, 1, 1, 0)) + var tex := GradientTexture2D.new() + tex.gradient = soft + tex.fill = GradientTexture2D.FILL_RADIAL + tex.fill_from = Vector2(0.5, 0.5) + tex.fill_to = Vector2(0.5, 0.0) + tex.width = 64 + tex.height = 64 + _dust_mat = StandardMaterial3D.new() + _dust_mat.albedo_texture = tex + _dust_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _dust_mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES + _dust_mat.vertex_color_use_as_albedo = true + _dust_mat.roughness = 1.0 + return _dust_mat diff --git a/scripts/fp/crews/crew_rig.gd.uid b/scripts/fp/crews/crew_rig.gd.uid new file mode 100644 index 00000000..02003f2f --- /dev/null +++ b/scripts/fp/crews/crew_rig.gd.uid @@ -0,0 +1 @@ +uid://4cqcpspe5002 diff --git a/scripts/fp/crews/crew_spots.gd b/scripts/fp/crews/crew_spots.gd new file mode 100644 index 00000000..a23864d6 --- /dev/null +++ b/scripts/fp/crews/crew_spots.gd @@ -0,0 +1,221 @@ +extends RefCounted +## Where a crew works on a construction site, from the site's CrewGrid: the door just inside the +## gap ConstructionKit leaves in the hoarding toward the frontage road (a waypoint), the +## foreman's spot to one side of it and a pile to the other, work spots nearest the middle of the +## site (a little toward the gate) that the door reaches, a drop point by the first work spot with +## the hauler's way there and back, and the point outside the gate an arriving crew comes from. +## route() is the way between two places on the site. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const GRID := preload("res://scripts/fp/crews/crew_grid.gd") +## A worker's footprint radius and height for the free test outside the site, and the gap kept +## between two. +const RADIUS := GRID.RADIUS +const HEIGHT := GRID.HEIGHT +const APART := 1.4 * WS.M +## ConstructionKit._fence's sides; without a frontage road the gate is side (CityView's tile +## hash) % 4. +const FENCE_SIDES: Array[Vector2i] = [ + Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0) +] +## The door, DOOR_IN in from the tile centre on the gate side, and the start outside the gate, +## GATE_OUT out (the hoarding stands at 0.47). +const DOOR_IN := 0.4 +const GATE_OUT := 0.56 +## Across the gate from the door: the foreman to one side, the pile to the other. A drop point is +## at most DROP_REACH from the work. +const FOREMAN_SIDE := 0.08 +const PILE_SIDE := 0.12 +const DROP_REACH := 0.2 +## Work spots are the ones nearest a point WORK_LEAN from the middle toward the gate: of two alike +## (the scaffold's sides), the one in sight of the road. +const WORK_LEAN := 0.06 +## Step between the points a straight walk outside the site is tested at. +const SAMPLE := 0.6 * WS.M +const NONE := Vector3(NAN, NAN, NAN) + + +## True when p is a point (not NONE). +static func found(p: Vector3) -> bool: + return not is_nan(p.x) + + +## The hoarding's open side on tile i as ConstructionKit draws it: toward the lot's frontage road, +## else side (CityView's tile hash) % 4 of FENCE_SIDES. +static func gate_side(directory: CityDirectory, i: int) -> Vector2i: + var f := directory.frontage(i) if directory != null else -1 + if f >= 0: + return Vector2i(D.tx(f) - D.tx(i), D.ty(f) - D.ty(i)) + return FENCE_SIDES[absi(D.tx(i) * 73856093 ^ D.ty(i) * 19349663) % 4] + + +## True for sites ConstructionKit hoards in: all but roads, bridges and added storeys. +static func fenced(s: Dictionary) -> bool: + return str(s.get("type", "")) != "road" and str(s.get("plan", "")) != "zone_up" + + +## The side a crew comes in from on tile i: the gate of a hoarded site (as drawn, else as it will +## be), else a side with a finished road, else south. +static func entry_side(ctx: FpContext, i: int) -> Vector2i: + var x := D.tx(i) + var y := D.ty(i) + var s: Dictionary = ctx.sim.sites.get(i, {}) + if not s.is_empty() and fenced(s): + var drawn := ctx.view.site_gate(i) if ctx.view != null else Vector2i.ZERO + return drawn if drawn != Vector2i.ZERO else gate_side(ctx.directory, i) + for d: Vector2i in FENCE_SIDES: + if D.in_bounds(x + d.x, y + d.y) and ctx.sim.kind[D.idx(x + d.x, y + d.y)] == D.ROAD: + return d + return Vector2i(0, 1) + + +## The crew's places on site i as drawn now: {"middle", "door" (a waypoint, shared), "gate" (the +## foreman's spot), "out", "pile", "drop" (Vector3, NONE when there is no room), "work": +## Array[Vector3] (up to n spots, nearest the middle first), "haul" / "back" (Array[Vector3], the +## way from the pile to the drop and back), "side": Vector2i (entry side), "grid": CrewGrid}. +## A spot the door does not reach (a scaffold board) is only taken when nothing else is left; +## without a door on the ground (the finished building fills the hoarding) every spot is reached. +static func layout(ctx: FpContext, i: int, n: int) -> Dictionary: + var g: GRID = GRID.new() + g.setup(ctx, i) + var c := g.c + var side := entry_side(ctx, i) + var dv := Vector3(side.x, 0.0, side.y) + var across := Vector3(-dv.z, 0.0, dv.x) + var none: Array[Vector3] = [] + var out := {"middle": c, "side": side, "grid": g, "drop": NONE, "haul": none, "back": none} + var at_door := c + dv * DOOR_IN + var door := g.near(at_door, GRID.CLIMB) + var reach := _reached(g, door) + var used: Array[Vector3] = [] + out["door"] = g.pos[door] if door >= 0 else NONE + out["gate"] = _take(g, reach, used, at_door - across * FOREMAN_SIDE) + var work: Array[Vector3] = [] + var lean := c + dv * WORK_LEAN + for _k in range(maxi(n, 0)): + var w := _take(g, reach, used, lean) + w = w if found(w) else _take(g, _reached(g, -1), used, lean) + if not found(w): + break + work.append(w) + out["work"] = work + out["pile"] = _take(g, reach, used, at_door + across * PILE_SIDE) + _haul(g, out, used) + out["out"] = _outside(ctx.collision, c, dv, out["door"]) + return out + + +## The way from a to b over lay's site: the points to walk through after a (from the node nearest +## a, a step up or down at most, when a is off it), ending at b's node; [] when there is none. +static func route(lay: Dictionary, a: Vector3, b: Vector3) -> Array[Vector3]: + var out: Array[Vector3] = [] + var g: GRID = lay.get("grid") + if g == null: + return out + var na := g.near(a, GRID.CLIMB) + out = g.path(na, g.near(b)) + if not out.is_empty() and g.pos[na].distance_to(a) > GRID.LEVEL_GAP: + out.push_front(g.pos[na]) + return out + + +## True when p is one of the places on lay's site where a worker fits. +static func stands(lay: Dictionary, p: Vector3) -> bool: + var g: GRID = lay.get("grid") + return g != null and g.stands(p) + + +## True when a person can stand at p (x, z) on the walkable ground (CollisionWorld). +static func fits(col: CollisionWorld, p: Vector3) -> bool: + var g := col.ground_height(p) + return not is_nan(g) and not col.body_blocked(Vector3(p.x, g, p.z), RADIUS, HEIGHT) + + +## True when a person walks from a to b in a straight line on the ground without running into +## anything. +static func line_clear(col: CollisionWorld, a: Vector3, b: Vector3) -> bool: + var n := maxi(ceili(_flat(a, b) / SAMPLE), 1) + for k in range(1, n): + if not fits(col, a.lerp(b, float(k) / float(n))): + return false + return true + + +## The nodes the door reaches (every node without a door). +static func _reached(g: GRID, door: int) -> Array[int]: + var out: Array[int] = [] + var parent := g.tree(door) if door >= 0 else PackedInt32Array() + for k in range(g.pos.size()): + if door < 0 or parent[k] >= 0: + out.append(k) + return out + + +## Takes the node of pool nearest target that keeps APART from every used point (and adds it to +## used); NONE when none is left. +static func _take(g: GRID, pool: Array[int], used: Array[Vector3], target: Vector3) -> Vector3: + var best := NONE + var best_d := INF + for k: int in pool: + var p := g.pos[k] + var d := _flat(p, target) + if d < best_d and _clear_of(p, used): + best = p + best_d = d + if found(best): + used.append(best) + return best + + +## The drop point by the first work spot that the pile reaches without the hauler walking into +## the rest of the crew, and the hauler's way there and back. +static func _haul(g: GRID, out: Dictionary, used: Array[Vector3]) -> void: + var pile: Vector3 = out["pile"] + var work: Array[Vector3] = out["work"] + if not found(pile) or work.is_empty(): + return + var a := g.near(pile) + var others: Array[Vector3] = [] + for u: Vector3 in used: + if u != pile: + others.append(u) + var skip := g.taken_by(others) + var parent := g.tree(a, skip) + var pool: Array[int] = [] + for k in range(g.pos.size()): + if parent[k] >= 0 and k != a and _flat(g.pos[k], work[0]) <= DROP_REACH: + pool.append(k) + var drop := _take(g, pool, used, work[0]) + if not found(drop): + return + var b := g.near(drop) + out["drop"] = drop + out["haul"] = g.path(a, b, skip) + out["back"] = g.path(b, a, skip) + + +## Where an arriving crew starts: just outside the gate when it can walk in to the door from +## there, else the door itself. +static func _outside(col: CollisionWorld, c: Vector3, dv: Vector3, door: Vector3) -> Vector3: + if col == null: + return door + for k: float in [GATE_OUT, GATE_OUT + 0.06, GATE_OUT + 0.12]: + var p := c + dv * k + if not fits(col, p): + continue + p.y = col.ground_height(p) + if not found(door) or line_clear(col, p, door): + return p + return door + + +static func _clear_of(p: Vector3, used: Array[Vector3]) -> bool: + for u: Vector3 in used: + if _flat(p, u) < APART: + return false + return true + + +static func _flat(a: Vector3, b: Vector3) -> float: + return Vector2(a.x - b.x, a.z - b.z).length() diff --git a/scripts/fp/crews/crew_spots.gd.uid b/scripts/fp/crews/crew_spots.gd.uid new file mode 100644 index 00000000..27fe0e28 --- /dev/null +++ b/scripts/fp/crews/crew_spots.gd.uid @@ -0,0 +1 @@ +uid://d34k55jq8qcai diff --git a/scripts/fp/crews/crews_feature.gd b/scripts/fp/crews/crews_feature.gd new file mode 100644 index 00000000..a03381b4 --- /dev/null +++ b/scripts/fp/crews/crews_feature.gd @@ -0,0 +1,508 @@ +extends FpFeature +## The player's building crews (integration feature "crews"). Every order placed from the phone +## (CitySim.apply_player_order) goes to one of D.CREWS crews, which builds it in real time: two +## to five minutes for a building at game speed 1, while later orders wait for a free crew. The +## crew is on site to see (CrewRig): a foreman by the gate, builders at work in the middle, a +## hauler carrying crates, dust while they work and a board over the gate with the crew, the +## order, the stage and the time left. A crew that takes an order while you play walks in through +## the gate, and a road crew moves along the road as the work goes on. The crew card under the top +## bar lists the orders with their stage, progress and countdown, and the ones still waiting. E on +## a worker gets a word about the job; a toast says when an order is done. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") +const BODIES := preload("res://scripts/fp/crews/crew_bodies.gd") +const SPOTS := preload("res://scripts/fp/crews/crew_spots.gd") +const RIG := preload("res://scripts/fp/crews/crew_rig.gd") +const CARD := preload("res://scripts/fp/crews/crew_card.gd") +const REFRESH := 0.25 +## A crew that takes an order waits this long for its site to be drawn before it is placed anyway +## (on foot CityView holds a rebuild in front of the camera back for up to 5 s). +const PLACE_WAIT := 6.0 +## Within this distance the card reads "here" instead of a distance. +const HERE := 0.75 +const DIRS := ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] +## Anything the interaction ray meets this much before a worker hides the worker. +const OCCLUDE := 0.3 * WS.M +## What a worker says on E: {stage}, {Stage} (capitalised), {label}, {left}. +const SAYINGS := { + "foreman": + [ + "We're {stage} on the {label}. Ready in {left}.", + "Right on schedule: the {label} opens in {left}.", + "{Stage} today. Hard hats past the gate, please.", + ], + "builder": + [ + "{Stage}. Should be done in {left}.", + "Busy {stage} here. {left} to go.", + "Mind your step, we're {stage}.", + ], + "hauler": + [ + "Another load for the {label}.", + "These crates won't carry themselves. {left} to go.", + "Keep clear of the pile, please.", + ], +} +## The photo shot: the camera stands on the sidewalk this far out from the middle of a lot whose +## hoarding gate faces a road and looks in through the gate, at an order PHOTO_DONE of the way. +const PHOTO_BACK := 0.62 +const PHOTO_DONE := 0.5 +const PHOTO_BUILDINGS := ["police", "fire_station", "school", "park"] + +var bodies: BODIES +var card: CARD +## Order id -> CrewRig, for every order a crew is on. +var rigs: Dictionary = {} +## Rigs waiting for their site to be drawn: order id -> {"arrive": bool, "t": seconds left}. +var _unplaced: Dictionary = {} +## Rigs to lay out again on the next frame: their site was redrawn, or the crew moved on. +var _dirty: Dictionary = {} +## Orders seen waiting for a crew (their start gets a toast). +var _queued: Dictionary = {} +var _tipped: bool = false +var _said: int = 0 +var _t: float = 0.0 +## The photo shot's {"lot": Vector2i, "mi": district}, {} without one. +var _photo: Dictionary = {} + + +func feature_id() -> String: + return "crews" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + bodies = BODIES.new() + if bodies.load_roster(): + bodies.start() + if ctx.hud != null: + card = CARD.new() + card.setup() + card.visible = false + ctx.hud.slot("center").add_child(card) + if ctx.sim != null: + link_signal(ctx.sim.crew_job_started, _on_started) + link_signal(ctx.sim.crew_job_finished, _on_finished) + if ctx.view != null: + link_signal(ctx.view.tile_built, _on_tile_built) + if ctx.bus != null: + link_signal(ctx.bus.interact_requested, _on_interact) + link_signal(ctx.bus.modal_changed, _sync_visibility) + + +## Boot, load or New City: the crews of the city as it is now, already at work. +func on_world_rebuilt() -> void: + _clear_rigs() + _queued.clear() + _refresh() + + +func teardown() -> void: + unlink_signals() + _clear_rigs() + if bodies != null: + bodies.finish_loads() + bodies.clear() + if card != null and is_instance_valid(card): + card.queue_free() + card = null + + +func _notification(what: int) -> void: + # Quitting frees the tree without teardown, and released bodies are out of the tree. + if what == NOTIFICATION_PREDELETE and bodies != null: + bodies.clear() + + +func to_dict() -> Dictionary: + return {"tipped": true} if _tipped else {} + + +func from_dict(d: Dictionary) -> void: + _tipped = bool(d.get("tipped", false)) + + +func self_test() -> Dictionary: + if ctx.hud != null and card == null: + return {"ok": false, "detail": "crew card missing"} + var who := ( + "%d trades bodies" % bodies.kinds.size() if bodies.ok else "stand-ins (%s)" % bodies.why + ) + return {"ok": true, "detail": "%s, %d crews at work" % [who, rigs.size()]} + + +## The worker nearest along the ray that nothing hides: {"kind": "crew", "id": ":", +## "tile", "pos" (its chest), "dist", "label", "data": {"job", "worker", "role"}}, or {}. +func pick(origin: Vector3, dir: Vector3, max_dist: float) -> Dictionary: + var best := {} + var by: RIG = null + for rig: RIG in rigs.values(): + var hit := rig.pick(origin, dir, max_dist) + if not hit.is_empty() and (best.is_empty() or float(hit["t"]) < float(best["t"])): + best = hit + by = rig + if by == null or _hidden(origin, dir, float(best["t"])): + return {} + var k := int(best["k"]) + var role := by.workers[k].role + var who := "a builder" if role == "builder" else "the " + role + return { + "kind": "crew", + "id": "%d:%d" % [by.job_id, k], + "tile": by.tile, + "pos": best["pos"], + "dist": float(best["t"]), + "label": "Talk to %s, crew %d" % [who, by.crew + 1], + "data": {"job": by.job_id, "worker": k, "role": role}, + } + + +## One shot through the gate of a site a crew works on ("crews"); photo_prepare places the order. +func photo_shots() -> Array: + _photo = _photo_lot() + if _photo.is_empty(): + return [] + var t: Vector2i = _photo["lot"] + var side := SPOTS.gate_side(ctx.directory, D.idx(t.x, t.y)) + var c := Vector3(t.x, 0.0, t.y) + var pos := c + Vector3(side.x, 0.0, side.y) * PHOTO_BACK + pos.y = ctx.collision.ground_height(pos) + var look := c - pos + var shot := {"name": "crews", "pos": pos, "yaw": rad_to_deg(atan2(-look.x, -look.z))} + shot.merge( + {"pitch": -4.0, "hour": 10.5, "prepare": "photo_prepare", "restore": "photo_restore"} + ) + return [shot] + + +## Photo tour: a phone order on the shot's lot, PHOTO_DONE of the way, its site drawn and its crew +## already at their places. +func photo_prepare() -> void: + if _photo.is_empty(): + return + bodies.blocking = true + var t: Vector2i = _photo["lot"] + for key: String in PHOTO_BUILDINGS: + var a := {"type": "build", "building": key, "x": t.x, "y": t.y} + var res := ctx.sim.apply_player_order(int(_photo["mi"]), a) + if res.has("job"): + ctx.sim.advance_crews(float(res["job"]["total"]) * PHOTO_DONE) + break + if ctx.view != null: + ctx.view.flush_rebuild_queue() + for u: Dictionary in _unplaced.values(): + u["arrive"] = false + _place_waiting(PLACE_WAIT) + _refresh() + + +func photo_restore() -> void: + bodies.blocking = false + + +func _process(delta: float) -> void: + if ctx == null or ctx.sim == null: + return + _place_waiting(delta) + _t -= delta + if _t <= 0.0: + _refresh() + var me := ctx.player_position() + for rig: RIG in rigs.values(): + if not rig.spots.is_empty(): + rig.step(delta, me) + + +# ------------------------------------------------------------------ sim signals + + +func _on_started(job: Dictionary) -> void: + var id := int(job["id"]) + var waited := _queued.has(id) + _queued.erase(id) + _rig_for(job, true) + if waited: + _toast("Crew %d is on the %s now." % [int(job["crew"]) + 1, job["label"]], "info") + # After the phone's own toast for the order that started it. + _tip.call_deferred() + _refresh() + + +func _on_finished(job: Dictionary) -> void: + var id := int(job["id"]) + _queued.erase(id) + _drop(id) + var cancelled := bool(job.get("cancelled", false)) + if cancelled: + _toast("Order cancelled: %s." % job["label"], "info") + elif int(job["crew"]) >= 0: + var c := int(job["crew"]) + 1 + _toast("%s is finished, built by crew %d." % [job["label"], c], "success") + Analytics.track("crew_done", {"cancelled": cancelled, "seconds": roundi(float(job["total"]))}) + _refresh() + + +## A redrawn site (a new stage) moves its machines and parts: the crew is laid out again once the +## collision boxes follow (CollisionWorld hears tile_built after the features). +func _on_tile_built(i: int, _holder: Node3D) -> void: + if ctx.view.in_setup: + return + for rig: RIG in rigs.values(): + if rig.tile == i: + _dirty[rig.job_id] = true + + +func _on_interact(t: Dictionary) -> void: + if str(t.get("kind", "")) != "crew": + return + var data: Dictionary = t.get("data", {}) + var rig: RIG = rigs.get(int(data.get("job", -1))) + var k := int(data.get("worker", -1)) + if rig == null or k < 0 or k >= rig.workers.size(): + return + rig.greet(k, ctx.player.eye_position() if ctx.player != null else ctx.player_position()) + var role := rig.workers[k].role + var line := _saying(rig, role) + _toast("%s, crew %d: “%s”" % [role.capitalize(), rig.crew + 1, line], "chat") + Analytics.track("crew_talk", {"role": role}) + + +# ------------------------------------------------------------------ rigs + + +## The rig for job once a crew is on it (arrive: it walks in once its site is drawn), or null. +func _rig_for(job: Dictionary, arrive: bool) -> RIG: + var id := int(job["id"]) + if rigs.has(id): + return rigs[id] + var tile := _work_tile(job) + if tile < 0 or int(job["crew"]) < 0: + return null + var rig: RIG = RIG.new() + rig.setup(bodies, job, tile) + add_child(rig) + rigs[id] = rig + var drawn := ctx.view == null or not ctx.view.site_boxes(tile).is_empty() + _unplaced[id] = {"arrive": arrive, "t": 0.0 if drawn or not arrive else PLACE_WAIT} + return rig + + +## Lays out the rigs whose site is drawn (or that waited PLACE_WAIT for it) and the dirty ones. +func _place_waiting(delta: float) -> void: + for id: int in _unplaced.keys(): + var u: Dictionary = _unplaced[id] + u["t"] = float(u["t"]) - delta + if float(u["t"]) <= 0.0: + _dirty[id] = true + if _dirty.is_empty() or ctx.collision == null: + return + var ids: Array = _dirty.keys() + _dirty.clear() + for id: int in ids: + var rig: RIG = rigs.get(id) + if rig != null: + var u: Dictionary = _unplaced.get(id, {}) + rig.place(ctx, bool(u.get("arrive", false))) + _unplaced.erase(id) + + +func _drop(id: int) -> void: + var rig: RIG = rigs.get(id) + rigs.erase(id) + _unplaced.erase(id) + _dirty.erase(id) + if rig != null and is_instance_valid(rig): + rig.release() + rig.queue_free() + + +func _clear_rigs() -> void: + for id: int in rigs.keys(): + _drop(id) + + +## The tile the crew works on: the order's only site, or along a road the one its progress has +## reached. +static func _work_tile(job: Dictionary) -> int: + var tiles: Array = job.get("tiles", []) + if tiles.is_empty(): + return -1 + var f := clampf(float(job["done"]) / maxf(float(job["total"]), 0.01), 0.0, 1.0) + return int(tiles[mini(int(f * tiles.size()), tiles.size() - 1)]) + + +# ------------------------------------------------------------------ card and boards + + +## The card, the boards and the rigs from the sim's crew orders (rigs also come and go here, for +## orders that started or ended while nothing listened, such as a load). +func _refresh() -> void: + _t = REFRESH + if ctx.sim == null: + return + var when := ctx.sim.crew_schedule() + var working: Array[Dictionary] = [] + var waiting: Array[Dictionary] = [] + for job: Dictionary in ctx.sim.crew_jobs: + if int(job["crew"]) < 0: + _queued[int(job["id"])] = true + waiting.append(_row(job, when)) + else: + _update_rig(job) + working.append(_row(job, when)) + var live := {} + for r: Dictionary in working: + live[int(r["id"])] = true + for id: int in rigs.keys(): + if not live.has(id): + _drop(id) + working.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["crew"] < b["crew"]) + working.append_array(waiting) + if card != null: + card.show_jobs(working, ctx.sim.crews_free(), D.CREWS) + _sync_visibility() + + +func _update_rig(job: Dictionary) -> void: + var rig := _rig_for(job, false) + if rig == null: + return + var tile := _work_tile(job) + if tile >= 0 and tile != rig.tile: + rig.tile = tile + _dirty[rig.job_id] = true + var s: Dictionary = ctx.sim.sites.get(rig.tile, {}) + rig.stage = ctx.sim.site_stage_name(s) if not s.is_empty() else "" + var title := "Crew %d · %s" % [rig.crew + 1, job["label"]] + rig.set_board(title, "%s · %s left" % [_stage_label(rig.stage), _left(job)]) + + +## A card row for job (CrewCard.show_jobs); when (CitySim.crew_schedule) dates a waiting order. +func _row(job: Dictionary, when: Dictionary) -> Dictionary: + var id := int(job["id"]) + var tile := _work_tile(job) + var r := {"id": id, "crew": int(job["crew"]), "label": job["label"], "stage": "", "step": ""} + r["fraction"] = clampf(float(job["done"]) / maxf(float(job["total"]), 0.01), 0.0, 1.0) + r["where"] = _where(tile) + if int(job["crew"]) < 0: + r["left"] = S.countdown((when.get(id, Vector2.ZERO) as Vector2).x, ctx.sim.speed) + return r + r["left"] = _left(job) + var s: Dictionary = ctx.sim.sites.get(tile, {}) + if not s.is_empty(): + r["stage"] = _stage_label(ctx.sim.site_stage_name(s)) + r["step"] = "stage %d of %d" % [int(s["stage"]) + 1, (s["stages"] as Array).size()] + return r + + +func _left(job: Dictionary) -> String: + return S.countdown(float(job["total"]) - float(job["done"]), ctx.sim.speed) + + +static func _stage_label(stage: String) -> String: + if stage.is_empty(): + return "getting started" + return str(D.STAGE_LABELS.get(stage, stage.replace("_", " "))) + + +## "240 m NE" from the player to tile i, "here" on it. +func _where(i: int) -> String: + if i < 0: + return "" + var me := ctx.player_position() + var d := Vector2(D.tx(i) - me.x, D.ty(i) - me.z) + if d.length() < HERE: + return "here" + var bearing := atan2(d.x, -d.y) + return "%s %s" % [S.distance(d.length()), DIRS[posmod(roundi(bearing / (PI / 4.0)), 8)]] + + +## The card shows while there are orders and nothing covers the street (the chat drawer, a menu, +## the map or the overview); the boards hide in photo mode. Also runs on bus.modal_changed: the +## pause menu stops _process. +func _sync_visibility(_modal := false) -> void: + if card != null: + var any := card.row_count() > 0 or not card.waiting_text().is_empty() + card.visible = any and not ctx.is_modal() + var boards := ctx.player == null or not ctx.player.photo_mode + for rig: RIG in rigs.values(): + rig.board.visible = boards + + +# ------------------------------------------------------------------ words + + +func _saying(rig: RIG, role: String) -> String: + var job := _job(rig.job_id) + var lines: Array = SAYINGS.get(role, SAYINGS["builder"]) + var stage := _stage_label(rig.stage) + var words := { + "stage": stage, + "Stage": stage.substr(0, 1).to_upper() + stage.substr(1), + "label": str(job.get("label", "site")).get_slice(",", 0), + "left": _left(job) if not job.is_empty() else "no time", + } + _said += 1 + return str(lines[_said % lines.size()]).format(words) + + +func _job(id: int) -> Dictionary: + for j: Dictionary in ctx.sim.crew_jobs: + if int(j["id"]) == id: + return j + return {} + + +func _tip() -> void: + if _tipped or ctx == null: + return + _tipped = true + _toast( + "Your crew is on site: go and watch them build, or press E by a worker for news.", "system" + ) + + +## True when a wall, a machine or the frame stands between origin and a worker t along dir. +func _hidden(origin: Vector3, dir: Vector3, t: float) -> bool: + if ctx.collision == null: + return false + var hit := ctx.collision.raycast(origin, dir, t, false) + return bool(hit["hit"]) and float(hit["dist"]) < t - OCCLUDE + + +# ------------------------------------------------------------------ photo + + +## The open lot nearest the player whose hoarding gate faces a road with sidewalk to stand on: +## {"lot": Vector2i, "mi": its district}, or {}. +func _photo_lot() -> Dictionary: + if ctx == null or ctx.sim == null or ctx.collision == null or not ctx.sim.staged(): + return {} + var me := ctx.player_position() + var best := {} + var best_d := INF + for mi in range(D.MAYORS.size()): + for lot: Array in ctx.sim.open_lots(mi, 400): + var t := Vector2i(int(lot[0]), int(lot[1])) + var d := Vector2(t.x - me.x, t.y - me.z).length() + if d < best_d and _photo_ok(t): + best = {"lot": t, "mi": mi} + best_d = d + return best + + +func _photo_ok(t: Vector2i) -> bool: + var side := SPOTS.gate_side(ctx.directory, D.idx(t.x, t.y)) + var n := t + side + if not D.in_bounds(n.x, n.y) or ctx.sim.kind[D.idx(n.x, n.y)] != D.ROAD: + return false + var pos := Vector3(t.x, 0.0, t.y) + Vector3(side.x, 0.0, side.y) * PHOTO_BACK + return SPOTS.fits(ctx.collision, pos) + + +func _toast(t: String, kind: String) -> void: + if ctx.bus != null: + ctx.bus.toast.emit(t, kind) diff --git a/scripts/fp/crews/crews_feature.gd.uid b/scripts/fp/crews/crews_feature.gd.uid new file mode 100644 index 00000000..dbc7ad06 --- /dev/null +++ b/scripts/fp/crews/crews_feature.gd.uid @@ -0,0 +1 @@ +uid://dbq7cvme4m0r6 diff --git a/scripts/fp/fp_feature.gd b/scripts/fp/fp_feature.gd index 702353a8..9d15c40e 100644 --- a/scripts/fp/fp_feature.gd +++ b/scripts/fp/fp_feature.gd @@ -5,6 +5,8 @@ extends Node3D ## the lifecycle: setup -> on_world_rebuilt (after every view.setup) -> to_dict/from_dict -> teardown. var ctx: FpContext +## [Signal, Callable] pairs connected through link_signal(), for unlink_signals(). +var _signal_links: Array[Array] = [] ## Stable id; also the key in ctx.features and in save["fp"]. Override. @@ -55,3 +57,20 @@ func self_test() -> Dictionary: ## the shot is placed and after it is saved, for example forcing rain and clearing it again. func photo_shots() -> Array: return [] + + +## Connects c to sig (once) and remembers the pair for unlink_signals(). +func link_signal(sig: Signal, c: Callable) -> void: + if not sig.is_connected(c): + sig.connect(c) + _signal_links.append([sig, c]) + + +## Disconnects every pair link_signal() connected whose source is still alive. Call from teardown. +func unlink_signals() -> void: + for l: Array in _signal_links: + var sig: Signal = l[0] + var src := sig.get_object() + if src != null and is_instance_valid(src) and sig.is_connected(l[1]): + sig.disconnect(l[1]) + _signal_links.clear() diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index 6053b149..ffcc19ad 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -18,4 +18,5 @@ const FEATURES := [ "id": "activity", "slice": "integration", "path": "res://scripts/fp/activity/activity_feature.gd", }, + {"id": "crews", "slice": "integration", "path": "res://scripts/fp/crews/crews_feature.gd"}, ] diff --git a/scripts/fp/interact/interact_feature.gd b/scripts/fp/interact/interact_feature.gd index 34756056..1c2861f8 100644 --- a/scripts/fp/interact/interact_feature.gd +++ b/scripts/fp/interact/interact_feature.gd @@ -275,9 +275,20 @@ func _building_target(i: int, hit_pos: Vector3) -> Dictionary: if not site.is_empty(): var planned := ctx.directory.planned_name(i, int(site.get("target_kind", -1)), int(site.get("target_level", 0))) label = ("%s (under construction)" % planned) if not planned.is_empty() else "Construction site" + var job := ctx.sim.crew_job_at(i) + if not job.is_empty(): + var what := planned if not planned.is_empty() else "Construction site" + label = "%s (%s)" % [what, _crew_note(job)] return {"kind": "building", "id": str(i), "tile": i, "pos": hit_pos, "dist": 0.0, "label": label, "data": {}} +## Who builds a player's order: "your crew 1 is on it", or "waiting for a free crew". No clock: +## the label only refreshes when the target changes (the site's board counts down). +func _crew_note(job: Dictionary) -> String: + var c := int(job.get("crew", -1)) + return "your crew %d is on it" % (c + 1) if c >= 0 else "waiting for a free crew" + + ## Named residents on foot near the ray when no traffic feature draws them: one pass over ## residents.people (indoor residents skipped, never ResidentAvatars.position_of), within ## TALK_RANGE of the head, inside RESIDENT_CONE, in line of sight. diff --git a/scripts/fp/phone/build_rules.gd b/scripts/fp/phone/build_rules.gd index d2250073..302702fc 100644 --- a/scripts/fp/phone/build_rules.gd +++ b/scripts/fp/phone/build_rules.gd @@ -208,3 +208,20 @@ static func _bulldoze(sim: CitySim, _mi: int, t: Vector2i, out: Dictionary) -> v out["ok"] = true out["cost"] = D.BULLDOZE_COST out["action"] = {"type": "bulldoze", "x": t.x, "y": t.y} + + +## Crew seconds the previewed order p would take one of the player's crews +## (CitySim.apply_player_order), or -1 when there is no estimate: zoning and bulldozing (what is +## on the tile decides), a refused order, or instant construction. +static func crew_seconds(sim: CitySim, tool: String, building: String, p: Dictionary) -> float: + if not sim.staged() or not bool(p.get("ok", false)): + return -1.0 + if tool == "build": + return D.build_crew_seconds(building) + if tool != "road": + return -1.0 + var secs := 0.0 + for t: Vector2i in p["tiles"]: + var wet := sim.terrain[D.idx(t.x, t.y)] == D.WATER + secs += float(D.CREW_TILE_S["bridge" if wet else "road"]) + return clampf(secs, D.CREW_JOB_MIN_S, D.CREW_MAX_S) diff --git a/scripts/fp/phone/phone_feature.gd b/scripts/fp/phone/phone_feature.gd index fa816612..6c490d47 100644 --- a/scripts/fp/phone/phone_feature.gd +++ b/scripts/fp/phone/phone_feature.gd @@ -1,10 +1,14 @@ extends FpFeature ## The phone and its Build app (integration feature "phone"). B opens a phone that orders roads, ## zones, buildings and bulldozing on the tile the hero looks at. Each order is placed for the -## mayor who owns that land and paid from that district's treasury, through CitySim.apply_action, -## so it follows the same rules (and starts the same construction sites) as the mayors' own orders. +## mayor who owns that land and paid from that district's treasury, through +## CitySim.apply_player_order, so it follows the same rules as the mayors' own orders; the +## construction sites it opens go to the player's crews, which build in real time (the crews +## feature draws them; with its flag off orders go to the contractors through apply_action). The +## phone says how many crews are free and how long an order would take. const D := preload("res://scripts/sim/sim_defs.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") const WS := preload("res://scripts/fp/world_scale.gd") const RULES := preload("res://scripts/fp/phone/build_rules.gd") const PHONE := preload("res://scripts/fp/phone/phone_ui.gd") @@ -174,9 +178,26 @@ func _state() -> Dictionary: var spec: Dictionary = D.BUILDINGS[building] st["building"] = "‹ %s %s ›" % [spec["label"], "$" + str(spec["cost"])] st["line"] = _line() + st["crews"] = _crews_line() return st +## How many of the player's crews are free and, for a road or a building, how long this order +## would take or when a crew frees up for it. "" while construction is instant. +func _crews_line() -> String: + var sim := ctx.sim + if not sim.staged() or not FeatureFlags.enabled("crews"): + return "" + var free := sim.crews_free() + var line := "Crews: %d of %d free" % [free, D.CREWS] if free > 0 else "Crews: all busy" + var secs := RULES.crew_seconds(sim, tool, building, preview) + if secs < 0.0: + return line + if free > 0: + return "%s · builds in %s" % [line, S.countdown(secs, sim.speed)] + return "%s · starts in %s" % [line, S.countdown(sim.crew_wait(), sim.speed)] + + ## True while a click here would do something: place the order, or set a first corner. func _can_click() -> bool: return bool(preview.get("ok", false)) or (_first() and int(preview.get("mi", -1)) >= 0) @@ -227,9 +248,13 @@ func place_at(t: Vector2i) -> Dictionary: _toast(str(preview.get("reason", "")), "alert") return {"ok": false, "reason": preview.get("reason", "")} var mi := int(preview["mi"]) - var res := ctx.sim.apply_action(mi, preview["action"]) + var a: Dictionary = preview["action"] + var crews := FeatureFlags.enabled("crews") + var res := ctx.sim.apply_player_order(mi, a) if crews else ctx.sim.apply_action(mi, a) if bool(res.get("ok", false)): - _toast("%s: %s" % [D.MAYORS[mi]["district"], _brief(str(res.get("reason", "")))], "success") + _toast(_placed(mi, res), "success") + if res.has("job"): + Analytics.track("crew_order", {"tool": tool, "queued": int(res["job"]["crew"]) < 0}) else: _toast(_sentence(str(res.get("reason", ""))), "alert") anchor = D.NO_TILE @@ -237,6 +262,20 @@ func place_at(t: Vector2i) -> Dictionary: return res +## The toast for a placed order: the district and what happened, or for a crew order who builds +## it and when ("Police Station: crew 1 on it, ready in 2:19"). +func _placed(mi: int, res: Dictionary) -> String: + var j: Dictionary = res.get("job", {}) + if j.is_empty(): + return "%s: %s" % [D.MAYORS[mi]["district"], _brief(str(res.get("reason", "")))] + var when: Vector2 = ctx.sim.crew_schedule().get(int(j["id"]), Vector2.ZERO) + var sp := ctx.sim.speed + if int(j["crew"]) >= 0: + var c := int(j["crew"]) + 1 + return "%s: crew %d on it, ready in %s" % [j["label"], c, S.countdown(when.y, sp)] + return "%s: all crews busy, starts in %s" % [j["label"], S.countdown(when.x, sp)] + + ## Drops the first corner, or closes the phone when there is none. func cancel() -> void: if anchor != D.NO_TILE: diff --git a/scripts/fp/phone/phone_ui.gd b/scripts/fp/phone/phone_ui.gd index 154e4582..d9dab9cd 100644 --- a/scripts/fp/phone/phone_ui.gd +++ b/scripts/fp/phone/phone_ui.gd @@ -1,7 +1,7 @@ extends Control ## The Build app's phone: who you build for (the district, its mayor and treasury), the six tools, -## the building picked for the Build tool, what placing here would do, and the keys. The feature -## fills it through show_state(); nothing here reads the sim. +## the building picked for the Build tool, what placing here would do, the player's crews, and the +## keys. The feature fills it through show_state(); nothing here reads the sim. const S := preload("res://scripts/fp/ui/hud_style.gd") const RULES := preload("res://scripts/fp/phone/build_rules.gd") @@ -17,6 +17,7 @@ var _treasury: Label var _tools: Dictionary = {} var _building: Label var _line: Label +var _crews: Label var _hint: Label var _shown: bool = false var _t: float = 0.0 @@ -63,6 +64,11 @@ func setup() -> void: _line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _line.custom_minimum_size = Vector2(WIDTH - 32.0, 0) v.add_child(_line) + _crews = S.plain("", 12, S.ACCENT, S.bold()) + _crews.name = "Crews" + _crews.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _crews.custom_minimum_size = Vector2(WIDTH - 32.0, 0) + v.add_child(_crews) _hint = S.plain("", 12, S.TEXT_DIM, S.caps()) _hint.name = "Hint" _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART @@ -114,7 +120,7 @@ func is_shown() -> bool: ## st: {"district", "mayor", "treasury" (int or null), "tool", "building" (label, "" hides), -## "line", "ok", "hint"}. +## "line", "ok", "crews" ("" hides), "hint"}. func show_state(st: Dictionary) -> void: _state = st _district.text = str(st.get("district", "")) @@ -133,6 +139,8 @@ func show_state(st: Dictionary) -> void: _building.visible = not _building.text.is_empty() _line.text = str(st.get("line", "")) _line.add_theme_color_override("font_color", S.TEXT if bool(st.get("ok", false)) else S.AMBER) + _crews.text = str(st.get("crews", "")) + _crews.visible = not _crews.text.is_empty() _hint.text = str(st.get("hint", "")) diff --git a/scripts/fp/ui/hud_style.gd b/scripts/fp/ui/hud_style.gd index 43e6d1bd..2ffcdbb7 100644 --- a/scripts/fp/ui/hud_style.gd +++ b/scripts/fp/ui/hud_style.gd @@ -334,6 +334,13 @@ static func distance(units: float) -> String: return "%d m" % int(round(m / 5.0) * 5.0) if m >= 20.0 else "%d m" % int(round(m)) +## "2:05": crew seconds (sim time) as minutes and seconds of real time at game speed (paused +## or stepping: as at speed 1). +static func countdown(crew_secs: float, speed: int) -> String: + var s := maxi(ceili(maxf(crew_secs, 0.0) / float(maxi(speed, 1))), 0) + return "%d:%02d" % [floori(s / 60.0), s % 60] + + ## True when text matches the model-branding regex (the B contract's no-branding check). static func branded(text: String) -> bool: var re := RegEx.create_from_string(BRAND_RE) diff --git a/scripts/sim/city_sim.gd b/scripts/sim/city_sim.gd index 80e785b2..cc55decd 100644 --- a/scripts/sim/city_sim.gd +++ b/scripts/sim/city_sim.gd @@ -18,6 +18,10 @@ signal incident_progress(ev: Dictionary) signal site_started(site: Dictionary) signal site_stage(site: Dictionary) signal site_finished(site: Dictionary) +## The player's crews (phone orders, apply_player_order): a crew took an order (also when it +## leaves the queue), and an order is done, or "cancelled" (true) when its sites were cleared. +signal crew_job_started(job: Dictionary) +signal crew_job_finished(job: Dictionary) ## Progress of a sliced tick; phase is a stable name ("recompute", "growth", "construction", ## "incidents", "residents", "commit", "day_end"); fraction 0..1 within the whole tick. signal tick_progress(phase: String, fraction: float) @@ -117,6 +121,16 @@ var active := PackedInt32Array() var sites: Dictionary = {} var construction_mode: String = "auto" var _site_seq: int = 0 +## The player's crew orders, oldest first: {"id", "owner", "label", "x", "y", "tiles" (its site +## tiles), "work" (the sites' contractor work), "total" and "done" (crew seconds), "crew" (crew +## index, -1 while it waits for a free crew)}. Their sites carry "job", the order id. +var crew_jobs: Array[Dictionary] = [] +var _crew_seq: int = 0 +## Crew seconds that came in while a sliced tick or spread work ran, applied once it is done. +var _crew_pending: float = 0.0 +## Set while apply_player_order places an order; the sites it opens are collected here. +var _ordering: bool = false +var _order_sites: Array[Dictionary] = [] ## Surveyed plots (Vector2i -> true), rebuilt with the active area. var _surveyed: Dictionary = {} ## Set by from_dict when the save used an older world layout: {w, h, x0, y0} of that layout, @@ -192,6 +206,9 @@ func new_city(seed_value: int) -> void: cov_transit = _floats(n) sites.clear() _site_seq = 0 + crew_jobs.clear() + _crew_seq = 0 + _crew_pending = 0.0 day = 1 tick_in_day = 0 _time_accum = 0.0 @@ -452,6 +469,8 @@ func set_speed(s: int) -> void: func _process(delta: float) -> void: if kind.is_empty(): return + if speed > 0 and not crew_jobs.is_empty(): + _crew_frame(delta * float(speed)) if sliced: _process_sliced(delta) return @@ -530,9 +549,14 @@ func skip_to_next_day() -> bool: _time_accum = 0.0 return not waiting_for_ai var n := D.GROWTH_TICKS_PER_DAY - tick_in_day + # The crews work through the skipped time too, a tick's worth before each growth tick. + var crew_s := maxf(tick_seconds() - _time_accum, 0.0) + _crew_pending + _crew_pending = 0.0 for _k in range(n): if waiting_for_ai: break + advance_crews(crew_s) + crew_s = tick_seconds() step_tick() _time_accum = 0.0 return not waiting_for_ai @@ -2163,6 +2187,11 @@ func _start_site(i: int, type: String, mi: int, target_kind: int, target_level: "stages": stages, "stage": 0, "progress": 0.0, "stage_work": float(maxi(ticks, 1)) * D.CREW_RATE / float(stages.size()), "crew": {}, "label": label, "start_tick": global_tick(), "rate": D.CREW_RATE, "tick_work": D.CREW_RATE, } + if _ordering: + # A player's order: its crew job takes this id once the order is placed. + s["job"] = _crew_seq + 1 + s["rate"] = 0.0 + _order_sites.append(s) sites[i] = s if type != "zone": _kind_set(i, D.SITE) @@ -2195,6 +2224,9 @@ func add_site_work(i: int, amount: float, who: String) -> bool: s["helper_work"] = used + amount if amount <= 0.0: return false + if int(s.get("job", 0)) > 0: + _help_crew(s, amount, who) + return false s["progress"] = float(s["progress"]) + amount s["tick_work"] = float(s.get("tick_work", 0.0)) + amount var crew: Dictionary = s["crew"] @@ -2217,7 +2249,8 @@ func _finish_site(s: Dictionary) -> void: level[i] = int(s["target_level"]) burning[i] = 0 _mark(i) - if str(s["type"]) == "build" or str(s["plan"]) == "zone_tower" or str(s["plan"]) == "bridge": + var logged := str(s["type"]) == "build" or str(s["plan"]) in ["zone_tower", "bridge"] + if logged and not s.has("job"): _log("%s at (%d,%d) is finished after %d ticks. Built by: %s." % [s["label"], int(s["x"]), int(s["y"]), global_tick() - int(s["start_tick"]), ", ".join(PackedStringArray(crew_names(s, 5)))], "build") site_finished.emit(s) @@ -2272,6 +2305,10 @@ func _task_construction(t: Dictionary) -> bool: s["rate"] = float(s.get("tick_work", D.CREW_RATE)) s["tick_work"] = 0.0 s["helper_work"] = 0.0 + if int(s.get("job", 0)) > 0: + # The player's crews move it on every frame (advance_crews), not per tick. + s["rate"] = 0.0 + continue if burning[i] > 0 or disabled_until.has(i): s["rate"] = 0.0 continue @@ -2288,6 +2325,10 @@ func finish_all_sites() -> void: _finish_site(s) else: sites.erase(i) + var done := crew_jobs.duplicate() + crew_jobs.clear() + for j: Dictionary in done: + crew_job_finished.emit(j) _recompute_deferred() _flush_changes(true) @@ -2493,6 +2534,260 @@ func total_funds() -> int: return t +# ---------------------------------------------------------------- player crews + +## apply_action for the player's own orders (the phone). The construction sites an order opens go +## to the player's crews instead of the contractors: D.CREWS crews build one order each in real +## time (D.crew_seconds) and later orders wait for a free crew. The result then also has "job", +## the order's entry in crew_jobs. +func apply_player_order(mi: int, a: Dictionary) -> Dictionary: + _external_entry() + _ordering = true + _order_sites.clear() + var res := apply_action(mi, a) + _ordering = false + if not _order_sites.is_empty(): + res["job"] = _open_crew_job(mi, _order_sites) + _order_sites.clear() + return res + + +func _open_crew_job(mi: int, order: Array[Dictionary]) -> Dictionary: + _crew_seq += 1 + var first: Dictionary = order[0] + var tiles: Array = [] + var secs := 0.0 + var work := 0.0 + for s: Dictionary in order: + var w := float(s["stage_work"]) * float((s["stages"] as Array).size()) + secs += D.crew_seconds(str(s["plan"]), roundi(w / D.CREW_RATE)) + work += w + tiles.append(int(s["i"])) + var label := str(first["label"]) + if order.size() > 1: + label = "%s, %d tiles" % [label, order.size()] + var j := { + "id": _crew_seq, "owner": mi, "label": label, "x": int(first["x"]), "y": int(first["y"]), + "tiles": tiles, "work": work, "done": 0.0, "crew": -1, + "total": clampf(secs, D.CREW_JOB_MIN_S, D.CREW_MAX_S), + } + crew_jobs.append(j) + _assign_crews() + return j + + +## Moves the player's crews on by seconds of work (real seconds at game speed): their sites go +## through their stages, finished orders open, and waiting orders get the crews that free up. +## _process feeds it every frame; skip_to_next_day and tests call it directly. +func advance_crews(seconds: float) -> void: + _external_entry() + if crew_jobs.is_empty(): + return + var left := maxf(seconds, 0.0) + var opened := _settle_crews() + # From one finish to the next, so a crew that finishes takes the next order in line with the + # rest of the time. + for _guard in range(2 * crew_jobs.size() + 2): + var nxt := _crew_next_finish() + var step := minf(left, nxt) + for j: Dictionary in crew_jobs: + if int(j["crew"]) >= 0: + j["done"] = float(j["done"]) + step + left -= step + if step < nxt: + break + opened = _settle_crews() or opened + for j: Dictionary in crew_jobs: + if int(j["crew"]) >= 0: + _crew_progress(j) + if opened: + _recompute_deferred() + if opened or not _changed.is_empty(): + _flush_changes(true) + + +## Crew time from _process. Sites change only between sliced ticks and spread work, so time that +## comes in during either waits in _crew_pending. +func _crew_frame(seconds: float) -> void: + _crew_pending += seconds + if _job.is_empty() and not _stepping and not _pumping and not _spread_pending(): + var s := _crew_pending + _crew_pending = 0.0 + advance_crews(s) + + +## Drops orders whose sites are gone, opens the finished ones and gives free crews the oldest +## waiting orders. Returns true when an order finished. +func _settle_crews() -> bool: + var finished := false + var k := 0 + while k < crew_jobs.size(): + var j: Dictionary = crew_jobs[k] + if _crew_sites_left(j) == 0: + crew_jobs.remove_at(k) + j["cancelled"] = true + crew_job_finished.emit(j) + elif int(j["crew"]) >= 0 and float(j["done"]) >= float(j["total"]): + crew_jobs.remove_at(k) + _finish_crew_job(j) + finished = true + else: + k += 1 + _assign_crews() + return finished + + +## Keeps the tiles of order j whose site is still that order's; returns how many are left. +func _crew_sites_left(j: Dictionary) -> int: + var keep: Array = [] + for i: int in j["tiles"]: + if sites.has(i) and int((sites[i] as Dictionary).get("job", 0)) == int(j["id"]): + keep.append(i) + j["tiles"] = keep + return keep.size() + + +func _finish_crew_job(j: Dictionary) -> void: + for i: int in j["tiles"]: + var s: Dictionary = sites[i] + if _site_valid(s): + _finish_site(s) + else: + sites.erase(i) + _mark(i) + var at := [j["label"], int(j["x"]), int(j["y"]), int(j["crew"]) + 1] + _log("%s at (%d,%d) is finished, built by the player's crew %d." % at, "build") + crew_job_finished.emit(j) + + +## Gives free crews the oldest waiting orders. +func _assign_crews() -> void: + var free: Array = range(D.CREWS) + for j: Dictionary in crew_jobs: + free.erase(int(j["crew"])) + for j: Dictionary in crew_jobs: + if free.is_empty(): + return + if int(j["crew"]) < 0: + j["crew"] = int(free.pop_front()) + crew_job_started.emit(j) + + +func _crew_next_finish() -> float: + var nxt := INF + for j: Dictionary in crew_jobs: + if int(j["crew"]) >= 0: + nxt = minf(nxt, maxf(float(j["total"]) - float(j["done"]), 0.0)) + return nxt + + +## Puts each site of a working order at the order's share of its crew time. +func _crew_progress(j: Dictionary) -> void: + var f := clampf(float(j["done"]) / maxf(float(j["total"]), 0.01), 0.0, 1.0) + for i: int in j["tiles"]: + var s: Dictionary = sites[i] + var n := (s["stages"] as Array).size() + var stage := mini(int(f * float(n)), n - 1) + s["progress"] = (f * float(n) - float(stage)) * float(s["stage_work"]) + if stage != int(s["stage"]): + s["stage"] = stage + _mark(i) + site_stage.emit(s) + + +## A resident's shift on a crew site moves the whole order on by the same share of its work. +func _help_crew(s: Dictionary, amount: float, who: String) -> void: + var j := crew_job_at(int(s["i"])) + if j.is_empty() or int(j["crew"]) < 0: + return + var share := amount / maxf(float(j["work"]), 1.0) + j["done"] = minf(float(j["done"]) + share * float(j["total"]), float(j["total"])) + var crew: Dictionary = s["crew"] + crew[who] = float(crew.get(who, 0.0)) + amount + + +## The crew order building tile i, or {} when tile i is not a crew site. +func crew_job_at(i: int) -> Dictionary: + if not sites.has(i): + return {} + var id := int((sites[i] as Dictionary).get("job", 0)) + for j: Dictionary in crew_jobs: + if int(j["id"]) == id: + return j + return {} + + +## When each crew order starts and is done, in crew seconds from now: {order id: +## Vector2(starts_in, done_in)}. Waiting orders go to whichever crew frees up first. +func crew_schedule() -> Dictionary: + var out := {} + _crew_plan(out) + return out + + +## Crew seconds until an order placed now would get a crew: 0 while one is free. +func crew_wait() -> float: + var free_at := _crew_plan({}) + var wait := INF + for t: float in free_at: + wait = minf(wait, t) + return wait + + +## Fills out like crew_schedule(); returns when each crew is free again, in crew seconds. +func _crew_plan(out: Dictionary) -> Array[float]: + var free_at: Array[float] = [] + free_at.resize(D.CREWS) + free_at.fill(0.0) + for j: Dictionary in crew_jobs: + var c := int(j["crew"]) + if c >= 0: + free_at[c] = maxf(float(j["total"]) - float(j["done"]), 0.0) + out[int(j["id"])] = Vector2(0.0, free_at[c]) + for j: Dictionary in crew_jobs: + if int(j["crew"]) < 0: + var c := 0 + for k in range(1, D.CREWS): + if free_at[k] < free_at[c]: + c = k + out[int(j["id"])] = Vector2(free_at[c], free_at[c] + float(j["total"])) + free_at[c] += float(j["total"]) + return free_at + + +## How many of the player's crews have no order. +func crews_free() -> int: + var busy := 0 + for j: Dictionary in crew_jobs: + busy += 1 if int(j["crew"]) >= 0 else 0 + return maxi(D.CREWS - busy, 0) + + +## Restores saved crew orders onto the loaded sites; a crew index that is out of range or taken +## twice waits for a free crew again. +func _load_crew_jobs(saved: Array, seq: int) -> void: + crew_jobs.clear() + var taken: Dictionary = {} + for v: Variant in saved: + var j: Dictionary = (v as Dictionary).duplicate(true) + for key: String in ["id", "owner", "x", "y", "crew"]: + j[key] = int(j.get(key, -1)) + for key: String in ["work", "total", "done"]: + j[key] = float(j.get(key, 0.0)) + var tiles: Array = [] + for s: Dictionary in sites.values(): + if int(s.get("job", 0)) == int(j["id"]): + tiles.append(int(s["i"])) + j["tiles"] = tiles + var c := int(j["crew"]) + if c >= D.CREWS or taken.has(c): + j["crew"] = -1 + elif c >= 0: + taken[c] = true + if not tiles.is_empty(): + crew_jobs.append(j) + _crew_seq = maxi(seq, crew_jobs.size()) + # ---------------------------------------------------------------- mayor actions ## Applies one AI (or player) action for the mayor at index mi. Returns {ok, reason, cost}. @@ -2648,7 +2943,7 @@ func _act_build(mi: int, a: Dictionary) -> Dictionary: _spend(mi, cost) var moved := "" if spot == Vector2i(x, y) else " (moved to %d,%d)" % [spot.x, spot.y] if staged(): - var plan := "park" if key == "park" else ("light" if D.LIGHT_BUILDS.has(key) else "build") + var plan := D.build_plan(key) var ticks := maxi(D.build_ticks(key), (D.SITE_STAGES[plan] as Array).size()) _start_site(i, "build", mi, int(spec["kind"]), 1, plan, ticks, str(spec["label"])) _log("%s broke ground on a %s at (%d,%d)." % [mayors[mi]["name"], spec["label"], spot.x, spot.y], "build") @@ -3278,6 +3573,7 @@ func to_dict() -> Dictionary: "disabled_until": disabled_until.duplicate(), "grid_priority": grid_priority.duplicate(), "broken_roads": broken_roads.duplicate(), "history": history.duplicate(true), "log": log_lines.duplicate(), "event_seq": _event_seq, "sites": sites.values().duplicate(true), "site_seq": _site_seq, + "crew_jobs": crew_jobs.duplicate(true), "crew_seq": _crew_seq, } @@ -3345,8 +3641,20 @@ func from_dict(data: Dictionary) -> bool: for l in data.get("log", []): log_lines.append(str(l)) _event_seq = int(data.get("event_seq", 0)) + _load_sites(data.get("sites", [])) + _site_seq = int(data.get("site_seq", sites.size())) + _load_crew_jobs(data.get("crew_jobs", []), int(data.get("crew_seq", 0))) + for i in range(n): + if kind[i] == D.SITE and not sites.has(i): + kind[i] = D.EMPTY + _recompute_all() + _flush_changes(true) + return true + + +func _load_sites(saved: Array) -> void: sites.clear() - for v: Variant in data.get("sites", []): + for v: Variant in saved: var s: Dictionary = (v as Dictionary).duplicate(true) for key in ["id", "i", "x", "y", "owner", "from_kind", "from_level", "target_kind", "target_level", "stage", "start_tick"]: s[key] = int(s.get(key, 0)) @@ -3355,14 +3663,10 @@ func from_dict(data: Dictionary) -> bool: s["stage_work"] = float(s.get("stage_work", D.CREW_RATE)) s["rate"] = float(s.get("rate", D.CREW_RATE)) s["tick_work"] = float(s.get("tick_work", D.CREW_RATE)) + if s.has("job"): + s["job"] = int(s["job"]) + s["rate"] = 0.0 sites[int(s["i"])] = s - _site_seq = int(data.get("site_seq", sites.size())) - for i in range(n): - if kind[i] == D.SITE and not sites.has(i): - kind[i] = D.EMPTY - _recompute_all() - _flush_changes(true) - return true ## Copies a saved per-tile array from the load_remap layout onto base (the current world). diff --git a/scripts/sim/sim_defs.gd b/scripts/sim/sim_defs.gd index f8970122..836ac116 100644 --- a/scripts/sim/sim_defs.gd +++ b/scripts/sim/sim_defs.gd @@ -124,6 +124,16 @@ const STAGE_LABELS := { } ## Light structures skip the excavation stage. const LIGHT_BUILDS := ["solar_farm", "wind_farm", "landfill"] +## The player's own building crews, for orders placed from the phone. A crew takes one order at a +## time and works it in real seconds at game speed; later orders wait for the next free crew. +const CREWS := 2 +## A park takes a crew CREW_MIN_S, the airport CREW_MAX_S, other buildings scale with their +## contractor time in between. Roads and clearing take CREW_TILE_S per tile. No order is quicker +## than CREW_JOB_MIN_S or slower than CREW_MAX_S. +const CREW_MIN_S := 120.0 +const CREW_MAX_S := 300.0 +const CREW_JOB_MIN_S := 45.0 +const CREW_TILE_S := {"road": 10.0, "bridge": 24.0, "clear": 30.0, "haul": 45.0} # Capacity per density level (index = level 0..3) const R_POP := [0, 16, 48, 120] @@ -260,6 +270,32 @@ static func build_ticks(key: String) -> int: return clampi(4 + int(BUILDINGS[key]["cost"]) / 90, 10, 60) +## Real seconds one of the player's crews needs for a site of plan that contractors alone would +## finish in ticks growth ticks. +static func crew_seconds(plan: String, ticks: int) -> float: + match plan: + "build", "light", "park": + # build_ticks runs from 4 (park) to 60 (airport). + return lerpf(CREW_MIN_S, CREW_MAX_S, clampf(float(ticks - 4) / 56.0, 0.0, 1.0)) + "demolish": + return 40.0 + 6.0 * float(ticks) + return float(CREW_TILE_S.get(plan, CREW_JOB_MIN_S)) + + +## The site plan for building key: the park and the light builds go through fewer stages. +static func build_plan(key: String) -> String: + if key == "park": + return "park" + return "light" if LIGHT_BUILDS.has(key) else "build" + + +## Crew seconds for one building of type key, from the order to the opening. +static func build_crew_seconds(key: String) -> float: + var plan := build_plan(key) + var ticks := maxi(build_ticks(key), (SITE_STAGES[plan] as Array).size()) + return clampf(crew_seconds(plan, ticks), CREW_JOB_MIN_S, CREW_MAX_S) + + static func is_power_plant(kind: int) -> bool: return kind == COAL or kind == SOLAR or kind == WIND diff --git a/scripts/world/city_view.gd b/scripts/world/city_view.gd index 06826464..ad78727a 100644 --- a/scripts/world/city_view.gd +++ b/scripts/world/city_view.gd @@ -1927,17 +1927,39 @@ func building_aabb(i: int) -> AABB: ## tile (a tower crane's base up to 0.2 u into the neighbours). [] when no site is drawn on tile i. func site_boxes(i: int) -> Array[AABB]: var out: Array[AABB] = [] - var holder: Node3D = _tile_nodes.get(i, null) - if holder == null or not is_instance_valid(holder): - return out - var site := holder.get_node_or_null("Site") as Node3D + var site := _site_root(i) if site == null: return out + var holder := site.get_parent() as Node3D for c: Dictionary in site.get_meta("colliders", []): out.append(holder.transform * ConstructionKit.collider_aabb(c)) return out +## The open side of site i's hoarding (ConstructionKit._fence: toward the frontage road), or +## Vector2i.ZERO without a fenced site drawn on tile i. +func site_gate(i: int) -> Vector2i: + var site := _site_root(i) + return site.get_meta("gate", Vector2i.ZERO) if site != null else Vector2i.ZERO + + +## Where people stand on site i's own parts as drawn now (ConstructionKit.decks_of: the pit, the +## slab as poured, the frame's floors shown so far, the scaffold's boards), in world space. +func site_decks(i: int) -> Array[Dictionary]: + var site := _site_root(i) + if site == null: + var none: Array[Dictionary] = [] + return none + return ConstructionKit.decks_of(site, (site.get_parent() as Node3D).transform) + + +func _site_root(i: int) -> Node3D: + var holder: Node3D = _tile_nodes.get(i, null) + if holder == null or not is_instance_valid(holder): + return null + return holder.get_node_or_null("Site") as Node3D + + ## Merged world-space bound of site_boxes(i): contains every blocking part the construction kit ## drew for the site on tile i, and can reach past the tile. AABB() when no site is drawn. For ## movement use site_boxes(i): this bound also covers the gaps between parts (the hoarding gate). diff --git a/scripts/world/construction_kit.gd b/scripts/world/construction_kit.gd index 9fd8ecc7..d27df494 100644 --- a/scripts/world/construction_kit.gd +++ b/scripts/world/construction_kit.gd @@ -19,6 +19,10 @@ const HOARDING_H_FP := 2.2 * WS.M const HOARDING_TINTS := [Color(0.33, 0.43, 0.34), Color(0.36, 0.4, 0.45), Color(0.86, 0.7, 0.5)] ## Machines keep their footprint inside the hoarding at +-HOARDING_IN. const HOARDING_IN := 0.46 +## A crew among the rebar keeps this far inside the footprint (clear of the formwork). +const DECK_IN := 0.025 +## The slab a frame stands on. +const SLAB_H := 0.03 const GRIME := "res://textures/real/decals/" var view: Node @@ -605,19 +609,24 @@ func build_site(holder: Node3D, s: Dictionary, stage: String, f: float, ctx: Dic shuttle(dz, Vector3.RIGHT, 0.18, 0.5, ph) machine("dump_truck", root, Vector3(0.18, 0, -0.3), PI, ph) "excavation": - # The pit widens and the spoil heaps grow as the digger works. - track(box(root, Vector3(foot.x, 0.004, foot.y), Vector3(0, 0.012, 0), "soil_dark", Vector3.ZERO, false), "sxz", 0.0, 1.0, 0.2, 1.0) + # The pit widens and the spoil heaps grow as the digger works. Each berm blocks on its own, + # so the pit inside is open ground a crew climbs into. + track(box(root, Vector3(foot.x, 0.004, foot.y), Vector3(0, _sheet_y(0.012, 0.004), 0), "soil_dark", Vector3.ZERO, false), "sxz", 0.0, 1.0, 0.2, 1.0) var berms := _group(root, "Berms") for side in [-1.0, 1.0]: - box(berms, Vector3(foot.x + 0.06, 0.035, 0.05), Vector3(0, 0.017, side * (foot.y * 0.5 + 0.02)), "soil") - box(berms, Vector3(0.05, 0.035, foot.y + 0.06), Vector3(side * (foot.x * 0.5 + 0.02), 0.017, 0), "soil") - solid(track(berms, "sy", 0.0, 1.0, 0.15, 1.0)) + solid(box(berms, Vector3(foot.x + 0.06, 0.035, 0.05), Vector3(0, 0.017, side * (foot.y * 0.5 + 0.02)), "soil")) + solid(box(berms, Vector3(0.05, 0.035, foot.y + 0.06), Vector3(side * (foot.x * 0.5 + 0.02), 0.017, 0), "soil")) + track(berms, "sy", 0.0, 1.0, 0.15, 1.0) solid(track(mound(root, 0.13, 0.08, Vector3(0.33, 0.0, 0.33), "soil"), "sy", 0.0, 1.0, 0.25, 1.3)) machine("excavator", root, Vector3(-0.3, 0.03, -0.18), 0.0, ph) machine("dump_truck", root, Vector3(0.3, 0, -0.33), PI * 0.5, ph) "foundation": - # Rebar goes in first, then the slab is poured across the formwork. - solid(track(box(root, Vector3(foot.x, 0.03, foot.y), Vector3(0, 0.015, 0), "concrete_wet"), "sx", 0.4, 1.0, 0.0, 1.0)) + # Rebar goes in first, then the slab is poured across the formwork; the crew stands among + # the rebar, then on the slab where it is poured. + var slab := box(root, Vector3(foot.x, 0.03, foot.y), Vector3.UP * 0.015, "concrete_wet") + solid(track(slab, "sx", 0.4, 1.0, 0.0, 1.0)) + deck(root, _inset(foot, DECK_IN), WS.GROUND_Y, 0.0) + deck(slab, _inset(foot, 0.0), 0.015, 0.03) var bars := _group(root, "Rebar") for side in [-1.0, 1.0]: solid(box(root, Vector3(foot.x + 0.02, 0.045, 0.012), Vector3(0, 0.022, side * foot.y * 0.5), "timber", Vector3.ZERO, false)) @@ -631,10 +640,12 @@ func build_site(holder: Node3D, s: Dictionary, stage: String, f: float, ctx: Dic machine("mixer", root, Vector3(0.28, 0, 0.36), PI, ph) machine("backhoe", root, Vector3(-0.32, 0, -0.34), 0.4, ph) "frame": - var base_h := float(ctx.get("base_height", 0.0)) if overlay else 0.0 + # The frame stands on its slab, or on the roof it adds storeys to. + var base_h := float(ctx.get("base_height", 0.0)) if overlay else SLAB_H if not overlay: - solid(box(root, Vector3(foot.x, 0.03, foot.y), Vector3(0, 0.015, 0), "concrete")) - _frame(root, foot, base_h, maxf(height, base_h + FLOOR_H), f) + var pad := Vector3(foot.x, SLAB_H, foot.y) + solid(box(root, pad, Vector3.UP * SLAB_H * 0.5, "concrete")) + _frame(root, foot, base_h, maxf(height, base_h + FLOOR_H), f, not overlay) if overlay: _scaffold(root, foot, height, false) machine("mixer", root, Vector3(0.3, 0, 0.4), PI, ph) @@ -649,7 +660,7 @@ func build_site(holder: Node3D, s: Dictionary, stage: String, f: float, ctx: Dic if not overlay: machine("dump_truck", root, Vector3(0.32, 0, 0.4), PI, ph + 2.0) "landscaping": - track(box(root, Vector3(0.6, 0.006, 0.4), Vector3(-0.05, 0.012, 0.08), "grass", Vector3.ZERO, false), "sxz", 0.0, 0.7, 0.15, 1.0) + track(box(root, Vector3(0.6, 0.006, 0.4), Vector3(-0.05, _sheet_y(0.012, 0.006), 0.08), "grass", Vector3.ZERO, false), "sxz", 0.0, 0.7, 0.15, 1.0) var planted := _group(root, "Planting") for k in range(5): var p := Vector3(float((h >> (k * 3)) % 7) * 0.1 - 0.3, 0, float((h >> (k * 2)) % 7) * 0.1 - 0.3) @@ -950,6 +961,80 @@ static func collider_aabb(c: Dictionary) -> AABB: return Transform3D(Basis(Vector3.UP, float(c.get("yaw", 0.0))), c["pos"] as Vector3) * local +## Marks where people stand on the site being built (crews on foot): rect (xz) at height y, the +## top of a part t thick, in n's frame, there while n shows. posts (xz, in the Site's frame) are +## kept clear on it (columns, a core). +func deck(n: Node3D, rect: Rect2, y: float, t: float, posts: Array[Rect2] = []) -> void: + var list: Array = _site_root.get_meta("decks", []) + list.append({"n": n, "rect": rect, "y": y, "t": t, "posts": posts}) + _site_root.set_meta("decks", list) + + +## A built Site root's decks as drawn now, in the frame xf puts its holder in: [{"rect": Rect2 +## (xz), "y": the top, "t": the thickness, "posts": Array[Rect2] (xz)}]; hidden ones are left out. +static func decks_of(site: Node3D, xf: Transform3D) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + var holder := site.get_parent() + var at := xf * _to_holder(site, holder) + for d: Dictionary in site.get_meta("decks", []): + var n: Variant = d["n"] + if not is_instance_valid(n) or not _shown_to(n as Node3D, holder): + continue + var t := xf * _to_holder(n as Node3D, holder) + var r: Rect2 = d["rect"] + var y := float(d["y"]) + var posts: Array[Rect2] = [] + for p: Rect2 in d["posts"]: + posts.append(_xz(at, p, 0.0)) + var top := (t * Vector3(r.get_center().x, y, r.get_center().y)).y + var thick := float(d["t"]) * t.basis.get_scale().y + out.append({"rect": _xz(t, r, y), "y": top, "t": thick, "posts": posts}) + return out + + +## The xz bounds of rect r (xz) at height y under t. +static func _xz(t: Transform3D, r: Rect2, y: float) -> Rect2: + var a := t * Vector3(r.position.x, y, r.position.y) + var out := Rect2(Vector2(a.x, a.z), Vector2.ZERO) + for c: Vector2 in [r.end, Vector2(r.position.x, r.end.y), Vector2(r.end.x, r.position.y)]: + var q := t * Vector3(c.x, y, c.y) + out = out.expand(Vector2(q.x, q.z)) + return out + + +## True while n and every node above it up to holder are visible. +static func _shown_to(n: Node3D, holder: Node) -> bool: + var x: Node = n + while x != null and x != holder: + if x is Node3D and not (x as Node3D).visible: + return false + x = x.get_parent() + return true + + +## The footprint foot (centred on the tile) shrunk by m on every side. +static func _inset(foot: Vector2, m: float) -> Rect2: + return Rect2(-foot * 0.5 + Vector2(m, m), foot - Vector2(m, m) * 2.0) + + +## The xz footprints of a frame floor's g x g columns, then its core (the Site's frame). +static func _frame_posts(foot: Vector2, g: int) -> Array[Rect2]: + var out: Array[Rect2] = [] + for gx in range(g): + for gz in range(g): + var px := (float(gx) / float(g - 1) - 0.5) * foot.x * 0.92 + var pz := (float(gz) / float(g - 1) - 0.5) * foot.y * 0.92 + out.append(Rect2(px - 0.0125, pz - 0.0125, 0.025, 0.025)) + out.append(Rect2(foot.x * 0.01, -foot.y * 0.11, foot.x * 0.22, foot.y * 0.22)) + return out + + +## The centre height of a ground sheet t thick (the pit floor, new turf): orbit_y over the orbit +## view's raised site ground; on foot it lies on the ground plane (_ground), round a worker's feet. +func _sheet_y(orbit_y: float, t: float) -> float: + return WS.GROUND_Y + 0.0004 + t * 0.5 if bool(view.get("fp_mode")) else orbit_y + + ## Writes the marked parts to the Site root's `colliders` meta, in holder space and at their full ## built size (before progress scales them): [{"shape": "box", "pos": Vector3 (centre of the base), ## "size": Vector3, "yaw": float}]. A shuttling machine's box is stretched over its whole run. @@ -1109,15 +1194,17 @@ func _ground(root: Node3D, stage: String) -> void: root.add_child(g) -## Site hoarding with a gate gap on one side: blue boards in orbit; on foot 2.2 m of painted plywood -## whose segment on the frontage side (`front`, toward the lot's frontage road) carries D's -## `poster_slot`. +## Site hoarding with a gate gap toward the frontage road (`front`; without one, side h % 4 of +## sides) for the trucks and the crew: blue boards in orbit; on foot 2.2 m of painted plywood whose +## segment on the frontage side carries D's `poster_slot`. The root's `gate` meta is the open side. func _fence(root: Node3D, h: int, front: Vector2i = Vector2i.ZERO) -> void: - var gate := h % 4 var fp := bool(view.get("fp_mode")) var hh := HOARDING_H_FP if fp else HOARDING_H var m := "hoarding_fp_%d" % ((h >> 5) % 3) if fp else "hoarding" var sides := [Vector2i(0, -1), Vector2i(1, 0), Vector2i(0, 1), Vector2i(-1, 0)] + var gate := sides.find(front) if front != Vector2i.ZERO else -1 + gate = gate if gate >= 0 else h % 4 + root.set_meta("gate", sides[gate]) for k in range(4): var along := Vector3(0.94, hh, 0.008) if k % 2 == 0 else Vector3(0.008, hh, 0.94) var pos := Vector3(sides[k].x * 0.47, hh * 0.5, sides[k].y * 0.47) @@ -1149,26 +1236,33 @@ func _floodlight(root: Node3D, pos: Vector3) -> void: head.name = "FloodHead" -## Concrete columns and floor slabs from base_h up to top_h, one child node per floor. -func _frame(root: Node3D, foot: Vector2, base_h: float, top_h: float, f: float) -> void: +## Concrete columns and floor slabs from base_h up to top_h, one child node per floor, each storey +## at least FLOOR_H tall (room for a crew under the floor over it). on_slab: base_h is the top of +## the slab, where a crew works among the ground floor's columns. +func _frame( + root: Node3D, foot: Vector2, base_h: float, top_h: float, f: float, on_slab := false +) -> void: var frame := Node3D.new() frame.name = "Frame" root.add_child(frame) solid(frame) - var floors := maxi(1, int(round((top_h - base_h) / FLOOR_H))) + var floors := maxi(1, int((top_h - base_h) / FLOOR_H)) var fh := (top_h - base_h) / float(floors) var g := 3 if foot.x > 0.55 else 2 + # A crew on a floor keeps off the columns and the core of the floor over it. + var posts := _frame_posts(foot, g) + if on_slab: + deck(frame, _inset(foot, 0.0), base_h, SLAB_H, posts) for k in range(floors): var fl := Node3D.new() fl.position.y = base_h + float(k) * fh frame.add_child(fl) _floors.append(fl) - for gx in range(g): - for gz in range(g): - var px := (float(gx) / float(g - 1) - 0.5) * foot.x * 0.92 - var pz := (float(gz) / float(g - 1) - 0.5) * foot.y * 0.92 - box(fl, Vector3(0.025, fh, 0.025), Vector3(px, fh * 0.5, pz), "concrete", Vector3.ZERO, false) + for c: Rect2 in posts.slice(0, g * g): + var at := Vector3(c.get_center().x, fh * 0.5, c.get_center().y) + box(fl, Vector3(0.025, fh, 0.025), at, "concrete", Vector3.ZERO, false) box(fl, Vector3(foot.x, 0.012, foot.y), Vector3(0, fh, 0), "concrete") + deck(fl, _inset(foot, 0.0), fh + 0.006, 0.012, posts) box(fl, Vector3(foot.x * 0.22, fh, foot.y * 0.22), Vector3(foot.x * 0.12, fh * 0.5, 0), "concrete_wet", Vector3.ZERO, false) for k in range(floors): var show := clampi(int(ceil(float(floors) * maxf(f, 0.05))), 1, floors) @@ -1201,6 +1295,11 @@ func _scaffold(root: Node3D, foot: Vector2, height: float, roll_net: bool) -> vo box(sc, Vector3(hx * 2.0, 0.006, 0.006), Vector3(0, y, side * hz), "steel", Vector3.ZERO, false) box(sc, Vector3(0.006, 0.006, hz * 2.0), Vector3(side * hx, y, 0), "steel", Vector3.ZERO, false) box(sc, Vector3(hx * 2.0, 0.004, 0.03), Vector3(0, y - 0.003, side * (hz + 0.012)), "timber", Vector3.ZERO, false) + var bx := Vector3(side * (hx + 0.012), y - 0.003, 0) + box(sc, Vector3(0.03, 0.004, hz * 2.0), bx, "timber", Vector3.ZERO, false) + # A crew stands on the boards, all four sides round. + deck(sc, Rect2(-hx, side * (hz + 0.012) - 0.015, hx * 2.0, 0.03), y - 0.001, 0.004) + deck(sc, Rect2(side * (hx + 0.012) - 0.015, -hz, 0.03, hz * 2.0), y - 0.001, 0.004) # Netting hangs from the top lift and is rolled up as the facade is finished. var net := _group(sc, "Net") net.position.y = top diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index 5f04d912..e4b942ee 100644 --- a/tools/tests/fp_core_test.gd +++ b/tools/tests/fp_core_test.gd @@ -79,7 +79,7 @@ func _check_registry(ctx: FpContext) -> void: ids[str(e["id"])] = true if f is Node: (f as Node).free() - K.check(fails, ok and ids.size() == 13, "all 13 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 14, "all 14 registry entries load as FpFeatures with matching unique ids") var feat := K.make_feature(ctx, "traffic") K.check(fails, feat != null and feat.is_inside_tree() and ctx.feature("traffic") == feat, "make_feature puts the feature in the tree and ctx.features") var st: Dictionary = feat.self_test() if feat != null else {} @@ -317,7 +317,8 @@ func _check_site(ctx: FpContext) -> void: sim.step_tick() sim.construction_mode = mode var r := WS.PLAYER_RADIUS - # Hoarding segments at ±0.47, 0.008 thick; side k of ConstructionKit._fence, gate side h % 4. + # Hoarding segments at ±0.47, 0.008 thick; side k of ConstructionKit._fence, the gate on the + # side CityView.site_gate names (toward the frontage road). var face := 0.474 var sides := [Vector3(0, 0, -1), Vector3(1, 0, 0), Vector3(0, 0, 1), Vector3(-1, 0, 0)] var walks := 0 @@ -332,7 +333,8 @@ func _check_site(ctx: FpContext) -> void: if str((sim.sites[i] as Dictionary)["type"]) != "build" or sim.terrain[i] == D.WATER or ctx.view.site_boxes(i).is_empty(): continue var c := _centre(i) - var gate := absi(D.tx(i) * 73856093 ^ D.ty(i) * 19349663) % 4 + var open := ctx.view.site_gate(i) + var gate := sides.find(Vector3(open.x, 0, open.y)) for k in range(4): var dir: Vector3 = sides[k] var start := c + dir * 0.66 diff --git a/tools/tests/fp_crew_jobs_test.gd b/tools/tests/fp_crew_jobs_test.gd new file mode 100644 index 00000000..d672c05e --- /dev/null +++ b/tools/tests/fp_crew_jobs_test.gd @@ -0,0 +1,340 @@ +extends SceneTree +## The player's crews (CitySim.apply_player_order): phone orders go to D.CREWS crews that build in +## real seconds at game speed (2 to 5 minutes for a building), later orders queue for a free crew, +## contractors and growth ticks leave crew sites alone, residents can help, a crew that finishes +## takes the next order with the rest of the time, and orders survive save/load, skipping to +## morning, bulldozing (cancel) and sliced ticks. + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") + +var fails: Array[String] = [] +var sim: CitySim +var started: Array[Dictionary] = [] +var finished: Array[Dictionary] = [] +var staged: Array[int] = [] + + +func _init() -> void: + await process_frame + _check_seconds() + var s := K.make_sim(get_root()) + sim = s["sim"] + sim.construction_mode = "staged" + sim.start() + sim.mayors[0]["treasury"] = 60000 + sim.crew_job_started.connect(func(j: Dictionary) -> void: started.append(j)) + sim.crew_job_finished.connect(func(j: Dictionary) -> void: finished.append(j)) + sim.site_stage.connect(func(st: Dictionary) -> void: staged.append(int(st["i"]))) + _check_order_and_queue() + _check_ticks() + _check_save_load() + _check_cancel() + _check_help() + _check_frames() + _check_skip() + _check_instant() + K.finish(self, "FP_CREW_JOBS_TEST", fails) + + +# ------------------------------------------------------------------ helpers + + +func _build(key: String) -> Dictionary: + var lot: Array = sim.open_lots(0, 10)[0] + var a := {"type": "build", "building": key, "x": int(lot[0]), "y": int(lot[1])} + return sim.apply_player_order(0, a) + + +func _tile(res: Dictionary) -> int: + return D.idx(int(res.get("x", -99)), int(res.get("y", -99))) + + +func _job(res: Dictionary) -> Dictionary: + return res.get("job", {}) + + +## A road over 5 open land tiles in a row in district 0. +func _road_order() -> Dictionary: + for i in range(D.W * D.H): + var x := D.tx(i) + var y := D.ty(i) + var n := 0 + while n < 5 and _open(x + n, y): + n += 1 + if n == 5: + return sim.apply_player_order(0, {"type": "road", "x": x, "y": y, "x2": x + 4, "y2": y}) + return {} + + +func _open(x: int, y: int) -> bool: + if not D.in_bounds(x, y): + return false + var i := D.idx(x, y) + return sim.district[i] == 0 and sim.kind[i] == D.EMPTY and sim.terrain[i] == D.LAND + + +# ------------------------------------------------------------------ checks + + +func _check_seconds() -> void: + var lo := INF + var hi := 0.0 + for key: String in D.BUILDINGS.keys(): + var secs := D.crew_seconds("park" if key == "park" else "build", D.build_ticks(key)) + lo = minf(lo, secs) + hi = maxf(hi, secs) + K.check( + fails, + is_equal_approx(D.crew_seconds("park", D.build_ticks("park")), D.CREW_MIN_S), + "a park takes a crew %.0f s" % D.CREW_MIN_S + ) + K.check( + fails, + is_equal_approx(D.crew_seconds("build", D.build_ticks("airport")), D.CREW_MAX_S), + "the airport takes a crew %.0f s" % D.CREW_MAX_S + ) + K.check( + fails, + lo >= 120.0 and hi <= 300.0, + "every building takes a crew 2 to 5 minutes (%.0f-%.0f s)" % [lo, hi] + ) + + +func _check_order_and_queue() -> void: + var park := _build("park") + var pj := _job(park) + var pi := _tile(park) + K.check( + fails, + bool(park.get("ok", false)) and not pj.is_empty(), + "a player's build order opens a crew job (%s)" % str(park.get("reason", "")) + ) + if pj.is_empty(): + return + K.check( + fails, + int(pj["crew"]) == 0 and started.size() == 1 and is_equal_approx(float(pj["total"]), 120.0), + "crew 1 takes the park at once, 120 s of work (%s)" % str(pj) + ) + K.check( + fails, + sim.kind[pi] == D.SITE and int(sim.sites[pi].get("job", 0)) == int(pj["id"]), + "the park's lot is a crew site" + ) + K.check(fails, sim.crew_job_at(pi) == pj, "crew_job_at finds the order from its tile") + var police := _build("police") + var school := _build("school") + var cj := _job(police) + var sj := _job(school) + K.check( + fails, + int(cj.get("crew", -9)) == 1 and int(sj.get("crew", -9)) == -1 and sim.crews_free() == 0, + "crew 2 takes the police station, the school waits for a free crew" + ) + var when: Dictionary = sim.crew_schedule() + var sw: Vector2 = when.get(int(sj.get("id", -1)), Vector2(-1, -1)) + K.check( + fails, + is_equal_approx(sw.x, 120.0) and is_equal_approx(sw.y, 120.0 + float(sj.get("total", 0.0))), + "the school starts when the park's crew is free (%s)" % str(sw) + ) + staged.clear() + sim.advance_crews(60.0) + var st: Dictionary = sim.sites.get(pi, {}) + K.check( + fails, + int(st.get("stage", -1)) == 1 and staged.has(pi) and sim.kind[pi] == D.SITE, + "half way the park is at its second stage (%s)" % sim.site_stage_name(st) + ) + K.check( + fails, + is_equal_approx(sim.site_fraction(st), 0.5), + "the site shows the crew's share of the work (%.2f)" % sim.site_fraction(st) + ) + # Every building but a park takes over 139 s, so the police station is still going at 130 s. + sim.advance_crews(60.0 + 10.0) + K.check(fails, sim.kind[pi] == D.PARK, "after 120 s the park is open") + K.check( + fails, + finished.size() == 1 and int(finished[0]["id"]) == int(pj["id"]), + "crew_job_finished reports the park" + ) + K.check( + fails, + int(sj["crew"]) == 0 and is_equal_approx(float(sj["done"]), 10.0), + "crew 1 moved on to the school with the rest of the time (%.1f s)" % float(sj["done"]) + ) + var ci := _tile(police) + sim.advance_crews(float(cj["total"]) - float(cj["done"]) + 1.0) + K.check(fails, sim.kind[ci] == D.POLICE, "the police station opens after its crew time") + K.check(fails, sim.crews_free() == 1, "one crew is free again") + var logged := false + for l: String in sim.log_lines: + logged = logged or l.contains("built by the player's crew") + K.check(fails, logged, "the city log says the player's crew built it") + var road := _road_order() + var rj := _job(road) + K.check( + fails, + int(rj.get("crew", -9)) == 1 and (rj.get("tiles", []) as Array).size() == 5, + "a road order is one crew job over all its tiles (%s)" % str(rj) + ) + sim.advance_crews(1000.0) + K.check(fails, sim.crew_jobs.is_empty(), "every order is done after enough crew time") + + +func _check_ticks() -> void: + var res := _build("park") + var i := _tile(res) + K.ticks(sim, D.build_ticks("park") + 2) + K.check( + fails, + sim.kind[i] == D.SITE and float(sim.sites[i]["progress"]) == 0.0, + "growth ticks and contractors leave a crew site alone" + ) + K.check(fails, float(sim.sites[i]["rate"]) == 0.0, "the view has no tick rate to extrapolate") + sim.advance_crews(1000.0) + + +func _check_save_load() -> void: + var res := _build("hospital") + var j := _job(res) + sim.advance_crews(40.0) + var saved: Variant = JSON.parse_string(JSON.stringify(sim.to_dict())) + var s2 := K.make_sim(get_root()) + var sim2: CitySim = s2["sim"] + K.check(fails, sim2.from_dict(saved as Dictionary), "a save with crew orders loads") + var j2: Dictionary = sim2.crew_job_at(_tile(res)) + K.check( + fails, + ( + not j2.is_empty() + and int(j2["crew"]) == int(j["crew"]) + and is_equal_approx(float(j2["done"]), 40.0) + and is_equal_approx(float(j2["total"]), float(j["total"])) + ), + "the order, its crew and its progress survive save/load (%s)" % str(j2) + ) + sim2.advance_crews(float(j2.get("total", 0.0))) + K.check( + fails, + sim2.kind[_tile(res)] == D.HOSPITAL and sim2.crew_jobs.is_empty(), + "the loaded crew finishes the hospital" + ) + sim.advance_crews(1000.0) + for key: String in ["people", "client", "sim"]: + (s2[key] as Node).queue_free() + + +func _check_cancel() -> void: + var res := _build("school") + var i := _tile(res) + finished.clear() + var bd := sim.apply_player_order(0, {"type": "bulldoze", "x": D.tx(i), "y": D.ty(i)}) + sim.advance_crews(0.0) + K.check( + fails, + bool(bd.get("ok", false)) and not sim.sites.has(i) and sim.crew_jobs.is_empty(), + "bulldozing a crew site cancels the order (%s)" % str(bd.get("reason", "")) + ) + K.check( + fails, + finished.size() == 1 and bool(finished[0].get("cancelled", false)), + "crew_job_finished reports it as cancelled" + ) + K.check(fails, sim.crews_free() == D.CREWS, "the crew is free again") + + +func _check_help() -> void: + var res := _build("fire_station") + var j := _job(res) + var i := _tile(res) + sim.add_site_work(i, 4.0, "Ann Lee (builder)") + var want := 4.0 / float(j["work"]) * float(j["total"]) + K.check( + fails, + is_equal_approx(float(j["done"]), want), + "a resident's shift moves the crew order on by its share (%.2f s)" % float(j["done"]) + ) + K.check( + fails, + (sim.sites[i]["crew"] as Dictionary).has("Ann Lee (builder)"), + "the helper is on the site's crew list" + ) + sim.add_site_work(i, 100.0, "Bo Ng (builder)") + K.check( + fails, + float(j["done"]) < float(j["total"]) * 0.2, + "helpers stay capped per tick (%.1f of %.1f s)" % [float(j["done"]), float(j["total"])] + ) + sim.advance_crews(1000.0) + + +func _check_frames() -> void: + # A long day, so no growth tick (and no resident shift) runs in these frames. + sim.set_day_seconds(1200.0) + var res := _build("park") + var j := _job(res) + sim.set_speed(2) + sim._process(1.5) + K.check( + fails, + is_equal_approx(float(j["done"]), 3.0), + "each frame moves the crews on by delta x speed (%.2f s)" % float(j["done"]) + ) + sim.set_speed(0) + sim._process(5.0) + K.check(fails, is_equal_approx(float(j["done"]), 3.0), "paused crews do not work") + sim.set_speed(1) + sim.sliced = true + K.check(fails, sim.begin_tick(), "fixture: a sliced tick in flight") + sim._crew_frame(2.0) + K.check( + fails, + is_equal_approx(float(j["done"]), 3.0), + "crew time waits while a sliced tick is in flight" + ) + sim._finish_job_now() + var before := float(j["done"]) + sim._crew_frame(0.0) + K.check( + fails, + is_equal_approx(float(j["done"]), before + 2.0), + "and is applied once the tick is done (%.2f s)" % float(j["done"]) + ) + sim.sliced = false + sim.advance_crews(1000.0) + + +func _check_skip() -> void: + # Start from dawn, so the skip covers a whole day of crew time. + sim.skip_to_next_day() + var res := _build("stadium") + var i := _tile(res) + var day := sim.day + sim.skip_to_next_day() + K.check( + fails, + sim.day == day + 1 and sim.kind[i] == D.STADIUM and sim.crew_jobs.is_empty(), + "skipping to morning lets the crews finish the stadium" + ) + sim.set_day_seconds(D.BASE_DAY_SECONDS) + var res2 := _build("park") + sim.finish_all_sites() + K.check( + fails, + sim.kind[_tile(res2)] == D.PARK and sim.crew_jobs.is_empty(), + "finish_all_sites also finishes the crews' orders" + ) + + +func _check_instant() -> void: + sim.construction_mode = "instant" + var res := _build("park") + K.check( + fails, + _job(res).is_empty() and sim.kind[_tile(res)] == D.PARK, + "with instant construction the order builds at once, no crew" + ) + sim.construction_mode = "staged" diff --git a/tools/tests/fp_crew_jobs_test.gd.uid b/tools/tests/fp_crew_jobs_test.gd.uid new file mode 100644 index 00000000..80f7d184 --- /dev/null +++ b/tools/tests/fp_crew_jobs_test.gd.uid @@ -0,0 +1 @@ +uid://c73uoyxymi2y0 diff --git a/tools/tests/fp_crews_test.gd b/tools/tests/fp_crews_test.gd new file mode 100644 index 00000000..080aee6c --- /dev/null +++ b/tools/tests/fp_crews_test.gd @@ -0,0 +1,468 @@ +extends SceneTree +## The player's crews on foot (integration feature "crews"): a phone order puts its crew on site, +## walking in through the gate to a foreman's spot by the gate, two builders in the middle and a +## hauler carrying crates from a pile, nobody inside the hoarding, a machine or the frame. A board +## over the gate and the crew card name the crew, the order, the stage and the time left, and list +## the orders still waiting. E on a worker gets a word about the job, the crew follows its site +## through the stages, a road crew moves along the road, and a finished or cancelled order clears +## its crew with a toast. Survives a world rebuild, a photo shot and teardown, and frees the bodies +## it keeps off the street when it is freed without teardown (quitting). + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const SPOTS := preload("res://scripts/fp/crews/crew_spots.gd") +const GRID := preload("res://scripts/fp/crews/crew_grid.gd") +const DT := 1.0 / 30.0 +## The hoarding's inner face, from the tile centre. +const INSIDE := 0.47 + +var fails: Array[String] = [] +var toasts: Array[String] = [] +var ctx: FpContext +var f: FpFeature +var mi: int = 0 +## Order ids and their tiles: police (crew 1), school (crew 2), park (waits, then crew 1). +var police: Dictionary = {} +var school: Dictionary = {} +var park: Dictionary = {} +var used: Array[int] = [] + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"view": true}) + ctx.sim.construction_mode = "staged" + ctx.sim.start() + ctx.sim.set_speed(0) + for m: Dictionary in ctx.sim.mayors: + m["treasury"] = 60000 + ctx.bus.toast.connect(func(t: String, _k: String) -> void: toasts.append(t)) + ctx.player.set_process(false) + f = K.make_feature(ctx, "crews") + K.check(fails, f != null and f.feature_id() == "crews", "the crews feature is registered") + if f == null: + K.finish(self, "FP_CREWS_TEST", fails) + return + f.set_process(false) + f.bodies.blocking = true + K.check( + fails, + f.bodies.ok or not f.bodies.why.is_empty(), + "the trades roster loads (%d bodies) or says why not" % f.bodies.kinds.size() + ) + var lot: Dictionary = f.call("_photo_lot") + K.check(fails, not lot.is_empty(), "fixture: an open lot whose gate faces a road") + if lot.is_empty(): + K.finish(self, "FP_CREWS_TEST", fails) + return + mi = int(lot["mi"]) + await _check_arrival(lot["lot"]) + _check_hauler() + _check_talk() + _check_queue() + _check_stages() + _check_finish() + _check_cancel() + _check_road() + _check_rebuild() + _check_photo() + _check_teardown() + _check_quit() + K.finish(self, "FP_CREWS_TEST", fails) + + +# ------------------------------------------------------------------ helpers + + +func _step(secs: float) -> void: + for _k in range(ceili(secs / DT)): + f._process(DT) + + +func _order(key: String, t: Vector2i) -> Dictionary: + var res := ctx.sim.apply_player_order( + mi, {"type": "build", "building": key, "x": t.x, "y": t.y} + ) + var j: Dictionary = res.get("job", {}) + if not j.is_empty(): + used.append(D.idx(int(res["x"]), int(res["y"]))) + return j + + +## Another open lot in the district, away from the ones already ordered. +func _free_lot() -> Vector2i: + for lot: Array in ctx.sim.open_lots(mi, 400): + var t := Vector2i(int(lot[0]), int(lot[1])) + var clear := true + for i: int in used: + clear = clear and maxi(absi(D.tx(i) - t.x), absi(D.ty(i) - t.y)) > 1 + if clear: + return t + return D.NO_TILE + + +func _rig(j: Dictionary) -> Node3D: + return f.rigs.get(int(j.get("id", -1)), null) + + +func _tile(j: Dictionary) -> int: + return int((j.get("tiles", [-1]) as Array)[0]) + + +func _toasted(part: String) -> bool: + for t: String in toasts: + if t.contains(part): + return true + return false + + +## Every worker at its place on the site stands where a person fits (on the ground, a slab, a +## floor or a scaffold board), on the ground only inside the hoarding of a fenced site. +func _all_fit(rig: Node3D, fenced: bool) -> bool: + var c := Vector3(D.tx(rig.tile), 0.0, D.ty(rig.tile)) + for w: Variant in rig.workers: + if w.state in ["off", "wait", "walk"]: + continue + if not SPOTS.stands(rig.spots, w.pos): + return false + var inside := absf(w.pos.x - c.x) < INSIDE and absf(w.pos.z - c.z) < INSIDE + if fenced and not inside and w.pos.y < c.y + GRID.CLIMB: + return false + return true + + +func _states(rig: Node3D) -> String: + var out := PackedStringArray() + for w: Variant in rig.workers: + out.append("%s:%s" % [w.role, w.state]) + return ", ".join(out) + + +# ------------------------------------------------------------------ checks + + +func _check_arrival(t: Vector2i) -> void: + var side := SPOTS.gate_side(ctx.directory, D.idx(t.x, t.y)) + var stand := Vector3(t.x, 0.0, t.y) + Vector3(side.x, 0.0, side.y) * 0.62 + stand.y = ctx.collision.ground_height(stand) + ctx.player.teleport(stand, rad_to_deg(atan2(side.x, side.y))) + police = _order("police", t) + K.check(fails, int(police.get("crew", -1)) == 0, "crew 1 takes the police station") + var i := _tile(police) + K.check(fails, not ctx.view.site_boxes(i).is_empty(), "fixture: the site is drawn at once") + var rig := _rig(police) + K.check(fails, rig != null, "the order's crew is on its site") + if rig == null: + return + _step(DT) + var out: Vector3 = rig.spots.get("out", SPOTS.NONE) + var at_gate := true + for w: Variant in rig.workers: + at_gate = at_gate and SPOTS.found(w.pos) and w.pos.distance_to(out) < 0.15 + K.check( + fails, + rig.workers.size() == 4 and at_gate and SPOTS.found(out), + "a foreman, two builders and a hauler come in from outside the gate (%s)" % _states(rig) + ) + var stagger := (rig.workers[3].state as String) == "wait" + K.check(fails, stagger, "they step off one after another") + _step(14.0) + var placed := true + for w: Variant in rig.workers: + placed = placed and w.state in ["work", "take", "put", "walk", "talk"] + K.check(fails, placed, "every worker reached the site (%s)" % _states(rig)) + var foreman: Variant = rig.workers[0] + K.check( + fails, + foreman.state == "work" and foreman.pos.distance_to(rig.spots["gate"]) < 0.01, + "the foreman keeps watch by the gate" + ) + var builders := 0 + for w: Variant in rig.workers: + var at_spot: bool = SPOTS.found(w.spot) and w.pos.distance_to(w.spot) < 0.01 + builders += 1 if w.role == "builder" and w.state == "work" and at_spot else 0 + K.check(fails, builders == 2, "both builders are at work in the middle") + K.check(fails, _all_fit(rig, true), "nobody stands in the hoarding, a machine or a slab") + K.check(fails, rig.working() == 4, "every worker has a body (%d)" % rig.working()) + var board: String = rig.board.text + K.check( + fails, + board.contains("Crew 1") and board.contains("Police Station") and board.contains("left"), + "the board names the crew, the order and the time left (%s)" % board.replace("\n", " / ") + ) + f.call("_refresh") + var row: String = f.card.row_text(int(police["id"])) + K.check( + fails, + f.card.visible and row.contains("Crew 1") and row.contains(D.STAGE_LABELS["clearing"]), + "the crew card lists the order and what the crew does (%s)" % row + ) + await process_frame + K.check(fails, _toasted("Your crew is on site"), "a one-time tip says where to find the crew") + K.check(fails, bool(f.to_dict().get("tipped", false)), "the tip is saved") + + +func _check_hauler() -> void: + var rig := _rig(police) + if rig == null: + return + var hauler: Variant = rig.workers[3] + if not SPOTS.found(rig.spots.get("drop", SPOTS.NONE)): + print(" skip the hauler: no clear way from the pile to the work on this site") + return + var carried := false + var shown := true + var put_down := false + for _k in range(ceili(30.0 / DT)): + f._process(DT) + carried = carried or hauler.carrying + put_down = put_down or (carried and not hauler.carrying) + if hauler.body != null and hauler.body.crate != null: + shown = shown and hauler.body.crate.visible == hauler.carrying + K.check( + fails, carried and put_down, "the hauler carries crates from the pile and sets them down" + ) + K.check(fails, shown, "the crate shows only while it is carried") + K.check(fails, _all_fit(rig, true), "the hauler's way stays clear of the site's parts") + + +func _check_talk() -> void: + var rig := _rig(police) + if rig == null: + return + var foreman: Variant = rig.workers[0] + var eye := ctx.player.eye_position() + var dir: Vector3 = (foreman.pos + Vector3.UP * 1.3 * WS.M - eye).normalized() + var t: Dictionary = f.pick(eye, dir, WS.INTERACT_RANGE * 2.0) + K.check( + fails, + ( + str(t.get("kind", "")) == "crew" + and str(t.get("label", "")) == "Talk to the foreman, crew 1" + ), + "the foreman answers the interaction ray through the gate (%s)" % str(t.get("label", "")) + ) + var ia: FpFeature = K.make_feature(ctx, "interact") + ia.set_process(false) + var best: Dictionary = ia.call("targets_along", eye, dir) + K.check(fails, str(best.get("kind", "")) == "crew", "E's target is the worker, not the site") + ctx.bus.interact_requested.emit(t) + K.check(fails, foreman.state == "talk", "the foreman stops to talk") + K.check(fails, _toasted("Foreman, crew 1: “"), "and says how the job goes") + _step(5.0) + K.check(fails, foreman.state == "work", "then goes back to work") + var away := eye + Vector3(-dir.x, 0.0, -dir.z).normalized() * 0.2 + K.check(fails, f.pick(away, -dir, 0.05).is_empty(), "a ray that misses the crew picks nothing") + ia.teardown() + ctx.features.erase("interact") + ia.queue_free() + + +func _check_queue() -> void: + school = _order("school", _free_lot()) + park = _order("park", _free_lot()) + K.check( + fails, + int(school.get("crew", -1)) == 1 and int(park.get("crew", 0)) == -1, + "crew 2 takes the school, the park waits" + ) + f.call("_refresh") + var wait: String = f.card.waiting_text() + K.check( + fails, + wait.contains("Park") and wait.contains("starts in") and f.card.free_text() == "all busy", + "the card lists the waiting order and when it starts (%s)" % wait + ) + K.check(fails, f.rigs.size() == 2, "only orders with a crew have one on site") + ctx.push_modal(&"crews_test") + K.check(fails, not f.card.visible, "the card hides under a menu or the chat") + ctx.pop_modal(&"crews_test") + K.check(fails, f.card.visible, "and comes back") + + +func _check_stages() -> void: + var rig := _rig(police) + if rig == null: + return + ctx.sim.advance_crews(float(police["total"]) * 0.5 - float(police["done"])) + _step(0.3) + var line: String = rig.board.text + K.check( + fails, + rig.stage == "foundation" and line.contains(D.STAGE_LABELS["foundation"]), + "the crew follows its site to the foundations (%s)" % line.replace("\n", " / ") + ) + K.check( + fails, + absf(f.card.row_fraction(int(police["id"])) - 0.5) < 0.02, + "the card's bar shows half the work done" + ) + _step(8.0) + K.check(fails, _all_fit(rig, true), "the redrawn site's new parts are kept clear too") + # The finished police station fills its lot: the facade is finished from the scaffold. + ctx.sim.advance_crews(float(police["total"]) * 0.9 - float(police["done"])) + ctx.view.flush_rebuild_queue() + _step(0.3) + var up := 0 + for w: Variant in rig.workers: + var on_board: bool = w.state == "work" and w.pos.y > GRID.CLIMB + up += 1 if w.role == "builder" and on_board else 0 + K.check( + fails, + rig.stage == "finishing" and up == 2, + "finishing the facade, the builders work from the scaffold (%s)" % _states(rig) + ) + K.check(fails, _all_fit(rig, true), "on its boards, clear of the building and the hoarding") + + +func _check_finish() -> void: + toasts.clear() + ctx.sim.advance_crews(float(police["total"]) - float(police["done"]) + 0.05) + K.check(fails, ctx.sim.kind[_tile(police)] == D.POLICE, "fixture: the police station opens") + K.check(fails, _rig(police) == null, "its crew leaves the site") + K.check( + fails, + _toasted("Police Station is finished, built by crew 1"), + "a toast says the order is done" + ) + K.check( + fails, + int(park.get("crew", -1)) == 0 and _rig(park) != null and _toasted("Crew 1 is on the Park"), + "crew 1 moves on to the waiting park, with a toast" + ) + + +func _check_cancel() -> void: + toasts.clear() + var i := _tile(park) + ctx.sim.apply_player_order(mi, {"type": "bulldoze", "x": D.tx(i), "y": D.ty(i)}) + ctx.sim.advance_crews(0.0) + K.check( + fails, + _rig(park) == null and _toasted("Order cancelled: Park"), + "bulldozing a crew site sends the crew away" + ) + + +func _check_road() -> void: + var res := {} + for k in range(D.W * D.H): + var x := D.tx(k) + var y := D.ty(k) + var n := 0 + while n < 5 and _open(x + n, y): + n += 1 + if n == 5: + res = ctx.sim.apply_player_order( + mi, {"type": "road", "x": x, "y": y, "x2": x + 4, "y2": y} + ) + break + var j: Dictionary = res.get("job", {}) + var rig := _rig(j) + K.check(fails, rig != null and int(j["crew"]) == 0, "a road order gets crew 1") + if rig == null: + return + var tiles: Array = j["tiles"] + K.check(fails, rig.tile == int(tiles[0]), "the road crew starts at one end") + ctx.sim.advance_crews(float(j["total"]) * 0.5 - float(j["done"])) + _step(0.3) + var middle: Vector3 = rig.spots.get("middle", SPOTS.NONE) + var at := Vector3(D.tx(int(tiles[2])), 0.0, D.ty(int(tiles[2]))) + K.check( + fails, + rig.tile == int(tiles[2]) and SPOTS.found(middle) and middle.distance_to(at) < 0.01, + "half way it works on the middle tile" + ) + _step(6.0) + K.check(fails, _all_fit(rig, false), "nobody stands in a road machine") + + +func _open(x: int, y: int) -> bool: + if not D.in_bounds(x, y): + return false + var i := D.idx(x, y) + return ctx.sim.district[i] == mi and ctx.sim.kind[i] == D.EMPTY and ctx.sim.terrain[i] == D.LAND + + +func _check_rebuild() -> void: + var before: int = f.rigs.size() + f.on_world_rebuilt() + _step(DT) + var settled := true + var states := PackedStringArray() + for rig: Variant in f.rigs.values(): + states.append(_states(rig)) + for w: Variant in rig.workers: + settled = settled and w.state != "wait" and w.state != "walk" + K.check( + fails, + f.rigs.size() == before and before >= 1 and settled, + ( + "after a rebuild (load) the crews are back at work at once (%d before: %s)" + % [before, "; ".join(states)] + ) + ) + var st: Dictionary = f.self_test() + K.check(fails, bool(st["ok"]), "self test: %s" % st["detail"]) + + +func _check_photo() -> void: + ctx.sim.advance_crews(1000.0) + K.check(fails, f.rigs.is_empty() and ctx.sim.crew_jobs.is_empty(), "fixture: every order done") + var shots: Array = f.photo_shots() + K.check(fails, shots.size() == 1, "one photo shot through a site gate") + if shots.is_empty(): + return + var s: Dictionary = shots[0] + K.check( + fails, + ( + s["name"] == "crews" + and s["prepare"] == "photo_prepare" + and s["restore"] == "photo_restore" + ), + "the shot prepares and restores itself" + ) + f.call(str(s["prepare"])) + ctx.player.teleport(s["pos"], float(s["yaw"])) + _step(DT) + var rig: Node3D = f.rigs.values()[0] if f.rigs.size() == 1 else null + var ready: bool = rig != null and rig.working() == 4 + for w: Variant in rig.workers if rig != null else []: + ready = ready and w.state != "wait" and w.state != "walk" + K.check(fails, ready, "the photo crew is at work as the shot is taken") + f.call(str(s["restore"])) + K.check(fails, not f.bodies.blocking, "restore lets bodies load in the background again") + + +func _check_teardown() -> void: + f.teardown() + K.check( + fails, + ( + f.rigs.is_empty() + and f.card == null + and not ctx.sim.crew_job_started.is_connected(f._on_started) + ), + "teardown frees the crews and the card and disconnects" + ) + + +func _check_quit() -> void: + var g: FpFeature = K.make_feature(ctx, "crews") + if g == null: + return + g.set_process(false) + g.bodies.blocking = true + var b: Variant = g.bodies.make(0, 0.5, g) + var body: Node3D = b.root if b != null else null + g.bodies.release(b) + g.bodies.finish_loads() + var off_street := body != null and body.get_parent() == null + g.free() + K.check( + fails, + off_street and not is_instance_valid(body), + "freeing the feature without teardown (quitting) frees the bodies it keeps off the street" + ) diff --git a/tools/tests/fp_crews_test.gd.uid b/tools/tests/fp_crews_test.gd.uid new file mode 100644 index 00000000..7f6ae4eb --- /dev/null +++ b/tools/tests/fp_crews_test.gd.uid @@ -0,0 +1 @@ +uid://du31po64dymyx diff --git a/tools/tests/fp_player_test.gd b/tools/tests/fp_player_test.gd index df595d82..dedc261f 100644 --- a/tools/tests/fp_player_test.gd +++ b/tools/tests/fp_player_test.gd @@ -665,7 +665,8 @@ func _check_sites(ctx: FpContext) -> void: if str((sim.sites[i] as Dictionary)["type"]) != "build" or sim.terrain[i] == D.WATER or ctx.view.site_boxes(i).is_empty(): continue var c := Vector3(D.tx(i), 0, D.ty(i)) - var gate := absi(D.tx(i) * 73856093 ^ D.ty(i) * 19349663) % 4 + var open := ctx.view.site_gate(i) + var gate := sides.find(Vector3(open.x, 0, open.y)) for k in range(4): var dir: Vector3 = sides[k] var start := c + dir * 0.66