From 371e8968bccbe6d534c744078871a02c794e6fe2 Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Tue, 20 Oct 2026 20:38:21 +0530 Subject: [PATCH] feat(fp): phone Build app that builds for the mayor who owns the land B opens a phone with a Build app on foot: road, homes, shops, industry, a building from the service list, and bulldoze, aimed at the tile ahead (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 starts the same construction sites as the mayors' own orders. Unclaimed land, roads into another district and districts too deep in debt are refused with a toast. 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. The phone goes away while driving or under a menu. Behind the "phone" flag, with fp_phone_test. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 2 +- data/feature_flags.json | 1 + docs/api/index.md | 7 + docs/api/scripts.fp.phone.md | 115 +++++++++ docs/fp/ARCHITECTURE.md | 3 +- project.godot | 5 + scripts/fp/fp_features.gd | 1 + scripts/fp/phone/build_ghost.gd | 93 ++++++++ scripts/fp/phone/build_ghost.gd.uid | 1 + scripts/fp/phone/build_rules.gd | 210 +++++++++++++++++ scripts/fp/phone/build_rules.gd.uid | 1 + scripts/fp/phone/phone_feature.gd | 326 ++++++++++++++++++++++++++ scripts/fp/phone/phone_feature.gd.uid | 1 + scripts/fp/phone/phone_ui.gd | 154 ++++++++++++ scripts/fp/phone/phone_ui.gd.uid | 1 + scripts/fp/ui/pause_menu.gd | 3 +- tools/release/PLAYING.md | 3 +- tools/tests/fp_core_test.gd | 2 +- tools/tests/fp_phone_test.gd | 314 +++++++++++++++++++++++++ tools/tests/fp_phone_test.gd.uid | 1 + 20 files changed, 1239 insertions(+), 5 deletions(-) create mode 100644 docs/api/scripts.fp.phone.md create mode 100644 scripts/fp/phone/build_ghost.gd create mode 100644 scripts/fp/phone/build_ghost.gd.uid create mode 100644 scripts/fp/phone/build_rules.gd create mode 100644 scripts/fp/phone/build_rules.gd.uid create mode 100644 scripts/fp/phone/phone_feature.gd create mode 100644 scripts/fp/phone/phone_feature.gd.uid create mode 100644 scripts/fp/phone/phone_ui.gd create mode 100644 scripts/fp/phone/phone_ui.gd.uid create mode 100644 tools/tests/fp_phone_test.gd create mode 100644 tools/tests/fp_phone_test.gd.uid diff --git a/README.md b/README.md index c2889652..7164c02a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,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. +- **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 orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index 86f66a6b..f3ebc702 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -12,6 +12,7 @@ "audio": {"default": true, "description": "First-person feature: street sound: buses, positional emitters and ambience (slice I)."}, "overview": {"default": true, "description": "First-person feature: Tab city overview to watch construction sites."}, "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."}, "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 b5e314e0..1bceac12 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -61,6 +61,13 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [overview_feature.gd](scripts.fp.overview.md#scriptsfpoverviewoverview_featuregd): City overview: Tab lifts the camera off the hero into an aerial view of the city, with a marker over every open construction site and a list of them +## [scripts/fp/phone/](scripts.fp.phone.md) + +- [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 + ## [scripts/fp/player/](scripts.fp.player.md) - [body_bake.gd](scripts.fp.player.md#scriptsfpplayerbody_bakegd): Bakes the walking collision of every building and zone model into body_table.gd (slice A) diff --git a/docs/api/scripts.fp.phone.md b/docs/api/scripts.fp.phone.md new file mode 100644 index 00000000..3a0d7481 --- /dev/null +++ b/docs/api/scripts.fp.phone.md @@ -0,0 +1,115 @@ + + +# `scripts/fp/phone/` + +[Index](index.md) + +## `scripts/fp/phone/build_ghost.gd` + +extends `Node3D` + +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. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `OK_COLOR = Color(0.0, 0.88, 1.0)` +- `BAD_COLOR = Color(1.0, 0.71, 0.28)` +- `FLOOR_ALPHA = 0.45` +- `COLUMN_ALPHA = 0.16` +- `LIFT = 0.4 * WS.M` +- `COLUMN_H = 4.0 * WS.M` +- `MAX_TILES = 64` + +### Functions + +- `func setup() -> void` +- `func show_tiles(tiles: Array, ok: bool, anchor: Vector2i) -> void`: Marks tiles (Array of Vector2i); anchor is the first corner or D.NO_TILE. +- `func clear() -> void` + +## `scripts/fp/phone/build_rules.gd` + +extends `RefCounted` + +Read-only previews for the phone Build app. Each one mirrors CitySim's own checks (_act_road, _act_zone, _act_build, _act_bulldoze) so the ghost tiles, the owner and the price match what apply_action will do. The sim stays the judge: it checks the order again when it is placed. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `TOOLS = ["road", "zone_r", "zone_c", "zone_i", "build", "bulldoze"]` +- `TOOL_LABELS = { "road": "Road", "zone_r": "Homes", "zone_c": "Shops", "zone_i": "Industry", "build": ...` +- `TWO_POINT = ["road", "zone_r", "zone_c", "zone_i"]`: Tools that take two corners (road start and end, zone rectangle); the rest act on one tile. +- `BUILDINGS = [ "park", "police", "fire_station", "water_pump", "school", "landfill", "hospital", "se...`: The Build tool's list, cheapest first. +- `DEBT_FLOOR = -2000`: CitySim._can_afford lets a mayor go this far into debt. +- `ZONES = {"zone_r": "R", "zone_c": "C", "zone_i": "I"}` + +### Functions + +- `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. + +## `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. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.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")` +- `GHOST = preload("res://scripts/fp/phone/build_ghost.gd")` +- `REACH = 6.0`: How far ahead of the hero the phone aims, in tiles: looking up, level and down. +- `AIM_LEVEL = 2.0` +- `NEAR = 0.6` +- `AIM_UP = deg_to_rad(25.0)` +- `AIM_DOWN = deg_to_rad(40.0)` +- `REFRESH = 0.1` +- `HINT = "LMB place RMB undo B close\n1-6 tools Wheel building"` + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func teardown() -> void` +- `func on_world_rebuilt() -> void` +- `func from_dict(_d: Dictionary) -> void` +- `func self_test() -> Dictionary` +- `func open_phone() -> bool`: Opens the phone; false while driving, in a menu or without a city. +- `func close_phone() -> void` +- `func set_tool(id: String) -> void` +- `func cycle_building(d: int) -> void`: Steps through the Build tool's list (d = +1 or -1). +- `func aim_tile() -> Vector2i`: The tile ahead of the hero along the camera's heading. A tile is 20 m and the eye under 2 m up, so a ray to the ground would swing by tiles per degree: the pitch picks the distance instead. +- `static func aim_distance(pitch: float) -> float`: Tiles ahead for a view pitch: AIM_LEVEL looking straight ahead, out to REACH looking up by AIM_UP, in to NEAR looking down by AIM_DOWN. +- `func refresh() -> void`: Re-aims and re-checks the order, then redraws the ghost and the phone. +- `func refresh_at(t: Vector2i) -> void`: Re-checks the order at tile t and redraws the ghost and the phone. +- `func place() -> Dictionary`: Places the order at the aimed tile. A road or zone takes two clicks: the first sets the corner. Returns apply_action's {ok, reason, cost}, {"ok": true, "pending": true} after a first corner, or {"ok": false, "reason"} when the preview already says no. +- `func place_at(t: Vector2i) -> Dictionary`: place() at tile t instead of the aimed tile. +- `func cancel() -> void`: Drops the first corner, or closes the phone when there is none. + +## `scripts/fp/phone/phone_ui.gd` + +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. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `RULES = preload("res://scripts/fp/phone/build_rules.gd")` +- `WIDTH = 280.0` +- `FROM_RIGHT = 24.0` +- `FROM_BOTTOM = 96.0` +- `SLIDE = 0.16` + +### Functions + +- `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 state() -> Dictionary`: The last state shown (tests read it). diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 1e3f8fc6..647489cb 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -96,7 +96,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - `city_sim.gd`: `var day_seconds: float = SimDefs.BASE_DAY_SECONDS`, read wherever `BASE_DAY_SECONDS` is used today. - `city_brain.gd` and `sim_defs.gd`: `MAYORS[i]["display"]` (human name). The display name replaces the model name in prompts, `city_log`, `owner_name()` and inspector text. `model` stays for the API. The offline Gazette editorial and the inspector prompt stop saying "AI mayors". - 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. +- `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. - `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) @@ -117,6 +117,7 @@ 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. | ## 6. Rules for every slice diff --git a/project.godot b/project.godot index e9266edd..49c0a9cd 100644 --- a/project.godot +++ b/project.godot @@ -260,6 +260,11 @@ drive_brake={ "events": [Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null) ] } +phone_toggle={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":66,"key_label":0,"unicode":98,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index fefeba78..7f7b85bf 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -13,4 +13,5 @@ const FEATURES := [ {"id": "audio", "slice": "I", "path": "res://scripts/fp/audio/audio_feature.gd"}, {"id": "overview", "slice": "integration", "path": "res://scripts/fp/overview/overview_feature.gd"}, {"id": "drive", "slice": "integration", "path": "res://scripts/fp/drive/drive_feature.gd"}, + {"id": "phone", "slice": "integration", "path": "res://scripts/fp/phone/phone_feature.gd"}, ] diff --git a/scripts/fp/phone/build_ghost.gd b/scripts/fp/phone/build_ghost.gd new file mode 100644 index 00000000..f8548375 --- /dev/null +++ b/scripts/fp/phone/build_ghost.gd @@ -0,0 +1,93 @@ +extends Node3D +## 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. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const OK_COLOR := Color(0.0, 0.88, 1.0) +const BAD_COLOR := Color(1.0, 0.71, 0.28) +const FLOOR_ALPHA := 0.45 +const COLUMN_ALPHA := 0.16 +const LIFT := 0.4 * WS.M +const COLUMN_H := 4.0 * WS.M +const MAX_TILES := 64 + +var _floor: MultiMeshInstance3D +var _column: MultiMeshInstance3D +var _floor_mat: StandardMaterial3D +var _column_mat: StandardMaterial3D +var _anchor: MeshInstance3D +var count: int = 0 + + +func setup() -> void: + name = "BuildGhost" + _floor_mat = _material() + _column_mat = _material() + _floor = _layer(Vector3(0.94, 0.3 * WS.M, 0.94), _floor_mat) + _column = _layer(Vector3(0.9, COLUMN_H, 0.9), _column_mat) + var ring := TorusMesh.new() + ring.inner_radius = 0.36 + ring.outer_radius = 0.44 + ring.material = _material() + (ring.material as StandardMaterial3D).albedo_color = Color(1, 1, 1, 0.9) + _anchor = MeshInstance3D.new() + _anchor.mesh = ring + _anchor.scale = Vector3(1.0, 0.25, 1.0) + _anchor.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _anchor.visible = false + add_child(_anchor) + visible = false + + +func _material() -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.cull_mode = BaseMaterial3D.CULL_DISABLED + return m + + +func _layer(size: Vector3, mat: StandardMaterial3D) -> MultiMeshInstance3D: + var box := BoxMesh.new() + box.size = size + box.material = mat + var mm := MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.mesh = box + mm.instance_count = MAX_TILES + mm.visible_instance_count = 0 + var mi := MultiMeshInstance3D.new() + mi.multimesh = mm + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mi) + return mi + + +## Marks tiles (Array of Vector2i); anchor is the first corner or D.NO_TILE. +func show_tiles(tiles: Array, ok: bool, anchor: Vector2i) -> void: + visible = true + var c := OK_COLOR if ok else BAD_COLOR + _floor_mat.albedo_color = Color(c, FLOOR_ALPHA) + _column_mat.albedo_color = Color(c, COLUMN_ALPHA) + count = mini(tiles.size(), MAX_TILES) + for k in range(count): + var t: Vector2i = tiles[k] + _floor.multimesh.set_instance_transform(k, Transform3D(Basis(), Vector3(t.x, LIFT, t.y))) + _column.multimesh.set_instance_transform( + k, Transform3D(Basis(), Vector3(t.x, LIFT + COLUMN_H * 0.5, t.y)) + ) + _floor.multimesh.visible_instance_count = count + _column.multimesh.visible_instance_count = count + _anchor.visible = anchor != D.NO_TILE + if _anchor.visible: + _anchor.position = Vector3(anchor.x, LIFT * 2.0, anchor.y) + + +func clear() -> void: + visible = false + count = 0 + _floor.multimesh.visible_instance_count = 0 + _column.multimesh.visible_instance_count = 0 + _anchor.visible = false diff --git a/scripts/fp/phone/build_ghost.gd.uid b/scripts/fp/phone/build_ghost.gd.uid new file mode 100644 index 00000000..c499b7e7 --- /dev/null +++ b/scripts/fp/phone/build_ghost.gd.uid @@ -0,0 +1 @@ +uid://7uenhlcskon6 diff --git a/scripts/fp/phone/build_rules.gd b/scripts/fp/phone/build_rules.gd new file mode 100644 index 00000000..d2250073 --- /dev/null +++ b/scripts/fp/phone/build_rules.gd @@ -0,0 +1,210 @@ +extends RefCounted +## Read-only previews for the phone Build app. Each one mirrors CitySim's own checks (_act_road, +## _act_zone, _act_build, _act_bulldoze) so the ghost tiles, the owner and the price match what +## apply_action will do. The sim stays the judge: it checks the order again when it is placed. + +const D := preload("res://scripts/sim/sim_defs.gd") + +const TOOLS: Array[String] = ["road", "zone_r", "zone_c", "zone_i", "build", "bulldoze"] +const TOOL_LABELS := { + "road": "Road", + "zone_r": "Homes", + "zone_c": "Shops", + "zone_i": "Industry", + "build": "Build", + "bulldoze": "Bulldoze", +} +## Tools that take two corners (road start and end, zone rectangle); the rest act on one tile. +const TWO_POINT: Array[String] = ["road", "zone_r", "zone_c", "zone_i"] +## The Build tool's list, cheapest first. +const BUILDINGS: Array[String] = [ + "park", + "police", + "fire_station", + "water_pump", + "school", + "landfill", + "hospital", + "sewage_plant", + "solar_farm", + "train_station", + "wind_farm", + "coal_plant", + "university", + "stadium", + "airport", +] +## CitySim._can_afford lets a mayor go this far into debt. +const DEBT_FLOOR := -2000 +const ZONES := {"zone_r": "R", "zone_c": "C", "zone_i": "I"} + + +## The mayor index that owns tile (x, y), or -1 for unclaimed land and outside the world. +static func owner_of(sim: CitySim, x: int, y: int) -> int: + if not D.in_bounds(x, y): + return -1 + var d := sim.district[D.idx(x, y)] + return d if d >= 0 and d < sim.mayors.size() else -1 + + +## The L-shaped road from a to b that CitySim builds: along x first, then along y. +static func road_path(a: Vector2i, b: Vector2i) -> Array[Vector2i]: + var path: Array[Vector2i] = [] + var sx := 1 if b.x >= a.x else -1 + for x in range(a.x, b.x + sx, sx): + path.append(Vector2i(x, a.y)) + if a.y != b.y: + var sy := 1 if b.y >= a.y else -1 + for y in range(a.y + sy, b.y + sy, sy): + path.append(Vector2i(b.x, y)) + if path.size() > D.MAX_ROAD_LEN: + path.resize(D.MAX_ROAD_LEN) + return path + + +## 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 preview( + sim: CitySim, tool: String, a: Vector2i, target: Vector2i, building: String +) -> Dictionary: + var from := a if a != D.NO_TILE and TWO_POINT.has(tool) else target + var mi := owner_of(sim, from.x, from.y) + var out := {"ok": false, "mi": mi, "tiles": [] as Array[Vector2i], "cost": 0, "reason": ""} + if not D.in_bounds(target.x, target.y): + out["reason"] = "Outside the city" + return out + if mi < 0: + out["reason"] = "Unclaimed land: no mayor to build for" + return out + match tool: + "road": + _road(sim, mi, from, target, out) + "zone_r", "zone_c", "zone_i": + _zone(sim, mi, from, target, str(ZONES[tool]), out) + "build": + _build(sim, mi, target, building, out) + "bulldoze": + _bulldoze(sim, mi, target, out) + _: + out["reason"] = "Unknown tool" + return out + if bool(out["ok"]) and int(sim.mayors[mi]["treasury"]) - int(out["cost"]) < DEBT_FLOOR: + out["ok"] = false + out["reason"] = "%s cannot afford it" % D.MAYORS[mi]["district"] + return out + + +static func _road(sim: CitySim, mi: int, a: Vector2i, b: Vector2i, out: Dictionary) -> void: + var tiles: Array[Vector2i] = [] + var cost := 0 + for p in road_path(a, b): + if not D.in_bounds(p.x, p.y): + out["reason"] = "The road leaves the city" + return + var i := D.idx(p.x, p.y) + var k := sim.kind[i] + if k == D.ROAD or (sim.sites.has(i) and int(sim.sites[i]["target_kind"]) == D.ROAD): + continue + if sim.district[i] != mi: + out["reason"] = "The road crosses into another district" + out["tiles"] = tiles + return + if not ( + k == D.EMPTY or k == D.FOREST or k == D.RUBBLE or (D.is_zone(k) and sim.level[i] == 0) + ): + out["reason"] = "Blocked by %s" % str(D.KIND_NAMES[k]).replace("_", " ") + out["tiles"] = tiles + return + tiles.append(p) + cost += D.ROAD_COST * (5 if sim.terrain[i] == D.WATER else 1) + out["tiles"] = tiles + if tiles.is_empty(): + out["reason"] = "There is a road here already" + return + out["ok"] = true + out["cost"] = cost + out["action"] = {"type": "road", "x": a.x, "y": a.y, "x2": b.x, "y2": b.y} + + +static func _zone( + sim: CitySim, mi: int, a: Vector2i, b: Vector2i, z: String, out: Dictionary +) -> void: + var x0 := mini(a.x, b.x) + var y0 := mini(a.y, b.y) + var w := mini(absi(b.x - a.x) + 1, D.MAX_ZONE_SIDE) + var h := mini(absi(b.y - a.y) + 1, D.MAX_ZONE_SIDE) + var zk := D.ZONE_R if z == "R" else (D.ZONE_C if z == "C" else D.ZONE_I) + var tiles: Array[Vector2i] = [] + for y in range(y0, y0 + h): + for x in range(x0, x0 + w): + if not D.in_bounds(x, y): + continue + var i := D.idx(x, y) + if sim.district[i] != mi or sim.terrain[i] == D.WATER: + continue + var k := sim.kind[i] + if k == D.EMPTY or k == D.FOREST or (D.is_zone(k) and k != zk and sim.level[i] == 0): + tiles.append(Vector2i(x, y)) + out["tiles"] = tiles + if tiles.is_empty(): + out["reason"] = "No open land to zone here" + return + out["ok"] = true + out["cost"] = tiles.size() * D.ZONE_COST + out["action"] = {"type": "zone", "zone": z, "x": x0, "y": y0, "w": w, "h": h} + + +static func _build(sim: CitySim, mi: int, t: Vector2i, key: String, out: Dictionary) -> void: + if not D.BUILDINGS.has(key): + out["reason"] = "Pick a building" + return + var spot := _build_spot(sim, mi, t) + if spot == D.NO_TILE: + out["reason"] = "No free lot here" + return + out["tiles"] = [spot] as Array[Vector2i] + out["ok"] = true + out["cost"] = int(D.BUILDINGS[key]["cost"]) + out["action"] = {"type": "build", "building": key, "x": t.x, "y": t.y} + + +## CitySim._find_build_spot: the tile itself, else a free lot within 2 tiles that has road access. +static func _build_spot(sim: CitySim, mi: int, t: Vector2i) -> Vector2i: + if _buildable(sim, mi, t.x, t.y): + return t + for r in range(1, 3): + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + var x := t.x + dx + var y := t.y + dy + if _buildable(sim, mi, x, y) and sim.road_dist[D.idx(x, y)] <= 2: + return Vector2i(x, y) + return D.NO_TILE + + +static func _buildable(sim: CitySim, mi: int, x: int, y: int) -> bool: + if not D.in_bounds(x, y): + return false + var i := D.idx(x, y) + if sim.district[i] != mi or sim.terrain[i] == D.WATER: + return false + var k := sim.kind[i] + return k == D.EMPTY or k == D.FOREST or k == D.RUBBLE or (D.is_zone(k) and sim.level[i] == 0) + + +static func _bulldoze(sim: CitySim, _mi: int, t: Vector2i, out: Dictionary) -> void: + var i := D.idx(t.x, t.y) + var k := sim.kind[i] + var demolishing := sim.sites.has(i) and int(sim.sites[i]["target_kind"]) == D.EMPTY + if k == D.EMPTY or k == D.HALL or demolishing: + out["reason"] = ( + "Nothing to clear" + if k == D.EMPTY + else ("City Hall stays" if k == D.HALL else "Already being cleared") + ) + return + out["tiles"] = [t] as Array[Vector2i] + out["ok"] = true + out["cost"] = D.BULLDOZE_COST + out["action"] = {"type": "bulldoze", "x": t.x, "y": t.y} diff --git a/scripts/fp/phone/build_rules.gd.uid b/scripts/fp/phone/build_rules.gd.uid new file mode 100644 index 00000000..e235b14b --- /dev/null +++ b/scripts/fp/phone/build_rules.gd.uid @@ -0,0 +1 @@ +uid://dw0wrtpmrowga diff --git a/scripts/fp/phone/phone_feature.gd b/scripts/fp/phone/phone_feature.gd new file mode 100644 index 00000000..fa816612 --- /dev/null +++ b/scripts/fp/phone/phone_feature.gd @@ -0,0 +1,326 @@ +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. + +const D := preload("res://scripts/sim/sim_defs.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") +const GHOST := preload("res://scripts/fp/phone/build_ghost.gd") +## How far ahead of the hero the phone aims, in tiles: looking up, level and down. +const REACH := 6.0 +const AIM_LEVEL := 2.0 +const NEAR := 0.6 +const AIM_UP := deg_to_rad(25.0) +const AIM_DOWN := deg_to_rad(40.0) +const REFRESH := 0.1 +const HINT := "LMB place RMB undo B close\n1-6 tools Wheel building" + +var open: bool = false +var tool: String = "road" +var building: String = "park" +var anchor: Vector2i = D.NO_TILE +var target: Vector2i = D.NO_TILE +var preview: Dictionary = {} +var phone: PHONE +var ghost: GHOST +var _driving: bool = false +var _t: float = 0.0 + + +func feature_id() -> String: + return "phone" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.hud != null: + phone = PHONE.new() + phone.setup() + ctx.hud.slot("overlay").add_child(phone) + if ctx.world_root != null: + ghost = GHOST.new() + ghost.setup() + ctx.world_root.add_child(ghost) + if ctx.bus != null: + ctx.bus.drive_changed.connect(_on_drive_changed) + + +func teardown() -> void: + close_phone() + if ctx != null and ctx.bus != null and ctx.bus.drive_changed.is_connected(_on_drive_changed): + ctx.bus.drive_changed.disconnect(_on_drive_changed) + for n: Node in [phone, ghost]: + if n != null and is_instance_valid(n): + n.queue_free() + phone = null + ghost = null + + +func on_world_rebuilt() -> void: + anchor = D.NO_TILE + if open: + refresh() + + +func from_dict(_d: Dictionary) -> void: + close_phone() + + +func self_test() -> Dictionary: + var path := RULES.road_path(Vector2i(0, 0), Vector2i(2, 1)) + return {"ok": path.size() == 4, "detail": "road (0,0)->(2,1): %d tiles" % path.size()} + + +# ------------------------------------------------------------------ phone + + +## Opens the phone; false while driving, in a menu or without a city. +func open_phone() -> bool: + if open: + return true + if _driving or ctx == null or ctx.sim == null or ctx.player == null or ctx.is_modal(): + return false + open = true + anchor = D.NO_TILE + if phone != null: + phone.set_shown(true) + refresh() + return true + + +func close_phone() -> void: + open = false + anchor = D.NO_TILE + preview = {} + if phone != null and is_instance_valid(phone): + phone.set_shown(false) + if ghost != null and is_instance_valid(ghost): + ghost.clear() + + +func set_tool(id: String) -> void: + if not RULES.TOOLS.has(id): + return + if id != tool: + anchor = D.NO_TILE + tool = id + refresh() + + +## Steps through the Build tool's list (d = +1 or -1). +func cycle_building(d: int) -> void: + var k := RULES.BUILDINGS.find(building) + building = RULES.BUILDINGS[posmod(k + d, RULES.BUILDINGS.size())] + refresh() + + +## The tile ahead of the hero along the camera's heading. A tile is 20 m and the eye under 2 m up, +## so a ray to the ground would swing by tiles per degree: the pitch picks the distance instead. +func aim_tile() -> Vector2i: + var p := ctx.player + var dir := p.forward() + if p.camera != null: + dir = -p.camera.global_basis.z + var flat := Vector2(dir.x, dir.z) + if flat.length() < 1e-3: + flat = Vector2(p.forward().x, p.forward().z) + var at := flat.normalized() * aim_distance(asin(clampf(dir.y, -1.0, 1.0))) + return Vector2i(roundi(p.position.x + at.x), roundi(p.position.z + at.y)) + + +## Tiles ahead for a view pitch: AIM_LEVEL looking straight ahead, out to REACH looking up by +## AIM_UP, in to NEAR looking down by AIM_DOWN. +static func aim_distance(pitch: float) -> float: + if pitch >= 0.0: + return lerpf(AIM_LEVEL, REACH, clampf(pitch / AIM_UP, 0.0, 1.0)) + return lerpf(AIM_LEVEL, NEAR, clampf(-pitch / AIM_DOWN, 0.0, 1.0)) + + +## Re-aims and re-checks the order, then redraws the ghost and the phone. +func refresh() -> void: + if open: + refresh_at(aim_tile()) + + +## Re-checks the order at tile t and redraws the ghost and the phone. +func refresh_at(t: Vector2i) -> void: + if not open: + return + target = t + preview = RULES.preview(ctx.sim, tool, anchor, target, building) + var tiles: Array = preview["tiles"] + if tiles.is_empty(): + tiles = [target] + if ghost != null: + ghost.show_tiles(tiles, _can_click(), anchor) + if phone != null: + phone.show_state(_state()) + + +func _state() -> Dictionary: + var mi := int(preview.get("mi", -1)) + var st := {"tool": tool, "ok": _can_click(), "hint": HINT} + if mi >= 0: + st["district"] = str(D.MAYORS[mi]["district"]) + st["mayor"] = "Acting for Mayor %s" % ctx.sim.mayors[mi]["name"] + st["treasury"] = int(ctx.sim.mayors[mi]["treasury"]) + else: + st["district"] = "Unclaimed land" + st["mayor"] = "No mayor builds here" + if tool == "build": + var spec: Dictionary = D.BUILDINGS[building] + st["building"] = "‹ %s %s ›" % [spec["label"], "$" + str(spec["cost"])] + st["line"] = _line() + return st + + +## 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) + + +func _first() -> bool: + return RULES.TWO_POINT.has(tool) and anchor == D.NO_TILE + + +func _line() -> String: + if _first() and int(preview.get("mi", -1)) >= 0: + return "Click to start the road here" if tool == "road" else "Click a first corner" + if not bool(preview.get("ok", false)): + return str(preview.get("reason", "")) + var n: int = (preview["tiles"] as Array).size() + var cost := "$%d" % int(preview["cost"]) + match tool: + "road": + return "%d tiles of road, %s" % [n, cost] + "zone_r", "zone_c", "zone_i": + var what := str(RULES.TOOL_LABELS[tool]).to_lower() + return "Zone %d tiles for %s, %s" % [n, what, cost] + "build": + return "Place a %s here, %s" % [D.BUILDINGS[building]["label"], cost] + return "Clear this lot, %s" % cost + + +## Places the order at the aimed tile. A road or zone takes two clicks: the first sets the corner. +## Returns apply_action's {ok, reason, cost}, {"ok": true, "pending": true} after a first corner, +## or {"ok": false, "reason"} when the preview already says no. +func place() -> Dictionary: + return place_at(aim_tile()) if open else {"ok": false, "reason": "the phone is closed"} + + +## place() at tile t instead of the aimed tile. +func place_at(t: Vector2i) -> Dictionary: + if not open: + return {"ok": false, "reason": "the phone is closed"} + refresh_at(t) + if _first(): + if int(preview.get("mi", -1)) < 0: + _toast(str(preview["reason"]), "alert") + return {"ok": false, "reason": preview["reason"]} + anchor = target + refresh_at(t) + return {"ok": true, "pending": true} + if not bool(preview.get("ok", false)): + _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"]) + if bool(res.get("ok", false)): + _toast("%s: %s" % [D.MAYORS[mi]["district"], _brief(str(res.get("reason", "")))], "success") + else: + _toast(_sentence(str(res.get("reason", ""))), "alert") + anchor = D.NO_TILE + refresh_at(t) + return res + + +## Drops the first corner, or closes the phone when there is none. +func cancel() -> void: + if anchor != D.NO_TILE: + anchor = D.NO_TILE + refresh() + else: + close_phone() + + +func _process(delta: float) -> void: + if not open: + return + if _driving or ctx == null or ctx.is_modal(): + close_phone() + return + _t -= delta + if _t <= 0.0: + _t = REFRESH + refresh() + + +func _input(event: InputEvent) -> void: + if ctx == null or ctx.player == null or event.is_echo(): + return + if not open: + if event.is_action_pressed("phone_toggle") and open_phone(): + get_viewport().set_input_as_handled() + return + if ctx.is_modal(): + return + var handled := true + if event.is_action_pressed("phone_toggle") or event.is_action_pressed("pause_menu"): + close_phone() + elif event is InputEventKey and (event as InputEventKey).pressed: + var k := int((event as InputEventKey).physical_keycode) - KEY_1 + if k >= 0 and k < RULES.TOOLS.size(): + set_tool(RULES.TOOLS[k]) + else: + handled = false + elif event is InputEventMouseButton and (event as InputEventMouseButton).pressed: + handled = _click(event as InputEventMouseButton) + else: + handled = false + if handled and is_inside_tree(): + get_viewport().set_input_as_handled() + + +func _click(mb: InputEventMouseButton) -> bool: + if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED and not DisplayServer.get_name() == "headless": + return false + match mb.button_index: + MOUSE_BUTTON_LEFT: + place() + MOUSE_BUTTON_RIGHT: + cancel() + MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN: + if tool != "build": + return false + cycle_building(-1 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else 1) + _: + return false + return true + + +func _on_drive_changed(active: bool) -> void: + _driving = active + if active: + close_phone() + + +func _toast(text: String, kind: String) -> void: + if ctx.bus != null and not text.is_empty(): + ctx.bus.toast.emit(text, kind) + + +## The sim's reason up to its first aside: "road construction started on 4 tiles (open in ..." +## -> "Road construction started on 4 tiles". +static func _brief(t: String) -> String: + for cut: String in [" (", ";"]: + var k := t.find(cut) + if k > 0: + t = t.left(k) + return _sentence(t) + + +static func _sentence(t: String) -> String: + return t.substr(0, 1).to_upper() + t.substr(1) if not t.is_empty() else t diff --git a/scripts/fp/phone/phone_feature.gd.uid b/scripts/fp/phone/phone_feature.gd.uid new file mode 100644 index 00000000..b97a17fc --- /dev/null +++ b/scripts/fp/phone/phone_feature.gd.uid @@ -0,0 +1 @@ +uid://cprb2hb2btjr8 diff --git a/scripts/fp/phone/phone_ui.gd b/scripts/fp/phone/phone_ui.gd new file mode 100644 index 00000000..abe92f8d --- /dev/null +++ b/scripts/fp/phone/phone_ui.gd @@ -0,0 +1,154 @@ +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. + +const S := preload("res://scripts/fp/ui/hud_style.gd") +const RULES := preload("res://scripts/fp/phone/build_rules.gd") +const WIDTH := 280.0 +const FROM_RIGHT := 24.0 +const FROM_BOTTOM := 96.0 +const SLIDE := 0.16 + +var _panel: PanelContainer +var _district: Label +var _mayor: Label +var _treasury: Label +var _tools: Dictionary = {} +var _building: Label +var _line: Label +var _hint: Label +var _shown: bool = false +var _t: float = 0.0 +var _state: Dictionary = {} + + +func setup() -> void: + name = "BuildPhone" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _panel = S.panel(Color(0.03, 0.04, 0.07, 0.9), 22, 16, 14, Color(1, 1, 1, 0.14)) + _panel.name = "Phone" + _panel.custom_minimum_size = Vector2(WIDTH, 0) + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", 6) + _panel.add_child(v) + var top := HBoxContainer.new() + top.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_child(top) + top.add_child(S.plain("BUILD", 13, S.ACCENT, S.caps())) + var gap := Control.new() + gap.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top.add_child(gap) + _treasury = S.plain("", 15, S.GOOD, S.bold()) + _treasury.name = "Treasury" + top.add_child(_treasury) + _district = S.plain("", 18, S.WHITE, S.title()) + _district.name = "District" + _district.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + v.add_child(_district) + _mayor = S.plain("", 13, S.TEXT_DIM, S.font()) + _mayor.name = "Mayor" + v.add_child(_mayor) + v.add_child(_rule()) + v.add_child(_tool_grid()) + _building = S.plain("", 15, S.TEXT, S.bold()) + _building.name = "Building" + v.add_child(_building) + v.add_child(_rule()) + _line = S.plain("", 14, S.TEXT, S.font()) + _line.name = "Line" + _line.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _line.custom_minimum_size = Vector2(WIDTH - 32.0, 0) + v.add_child(_line) + _hint = S.plain("", 12, S.TEXT_DIM, S.caps()) + _hint.name = "Hint" + _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _hint.custom_minimum_size = Vector2(WIDTH - 32.0, 0) + v.add_child(_hint) + add_child(_panel) + _panel.visible = false + + +func _rule() -> ColorRect: + var r := ColorRect.new() + r.mouse_filter = Control.MOUSE_FILTER_IGNORE + r.color = S.PANEL_LINE + r.custom_minimum_size = Vector2(0, 1) + return r + + +func _tool_grid() -> GridContainer: + var g := GridContainer.new() + g.mouse_filter = Control.MOUSE_FILTER_IGNORE + g.columns = 3 + g.add_theme_constant_override("h_separation", 6) + g.add_theme_constant_override("v_separation", 6) + for k in range(RULES.TOOLS.size()): + var id: String = RULES.TOOLS[k] + var cell := PanelContainer.new() + cell.mouse_filter = Control.MOUSE_FILTER_IGNORE + cell.custom_minimum_size = Vector2(76, 0) + var h := HBoxContainer.new() + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + h.add_theme_constant_override("separation", 5) + cell.add_child(h) + h.add_child(S.plain(str(k + 1), 11, S.TEXT_FAINT, S.bold())) + h.add_child(S.plain(str(RULES.TOOL_LABELS[id]), 13, S.TEXT, S.bold())) + g.add_child(cell) + _tools[id] = cell + return g + + +## Slides the phone in (true) or out. +func set_shown(on: bool) -> void: + _shown = on + if on: + _panel.visible = true + + +func is_shown() -> bool: + return _shown + + +## st: {"district", "mayor", "treasury" (int or null), "tool", "building" (label, "" hides), +## "line", "ok", "hint"}. +func show_state(st: Dictionary) -> void: + _state = st + _district.text = str(st.get("district", "")) + _mayor.text = str(st.get("mayor", "")) + var t: Variant = st.get("treasury", null) + _treasury.text = S.money(int(t)) if t != null else "" + _treasury.add_theme_color_override("font_color", S.GOOD if t != null and int(t) >= 0 else S.RED) + for id: String in _tools: + var on := id == str(st.get("tool", "")) + var bg := Color(0.0, 0.88, 1.0, 0.16) if on else Color(1, 1, 1, 0.05) + var line := S.ACCENT if on else Color(0, 0, 0, 0) + (_tools[id] as PanelContainer).add_theme_stylebox_override( + "panel", S.box(bg, 8, 8, 5, line, 1 if on else 0) + ) + _building.text = str(st.get("building", "")) + _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) + _hint.text = str(st.get("hint", "")) + + +## The last state shown (tests read it). +func state() -> Dictionary: + return _state + + +func _process(delta: float) -> void: + _t = clampf(_t + (delta if _shown else -delta) / SLIDE, 0.0, 1.0) + _panel.visible = _t > 0.0 + if not _panel.visible: + return + var view := size if size.x > 0.0 else get_viewport_rect().size + var sz := _panel.get_combined_minimum_size() + _panel.size = sz + var ease_t := 1.0 - pow(1.0 - _t, 3.0) + var x := view.x - FROM_RIGHT - sz.x + (1.0 - ease_t) * (sz.x + FROM_RIGHT) + _panel.position = Vector2(x, view.y - FROM_BOTTOM - sz.y).round() + _panel.modulate.a = ease_t diff --git a/scripts/fp/phone/phone_ui.gd.uid b/scripts/fp/phone/phone_ui.gd.uid new file mode 100644 index 00000000..6551b502 --- /dev/null +++ b/scripts/fp/phone/phone_ui.gd.uid @@ -0,0 +1 @@ +uid://cy2bacg2cmxm0 diff --git a/scripts/fp/ui/pause_menu.gd b/scripts/fp/ui/pause_menu.gd index 87daf028..a22c5bd8 100644 --- a/scripts/fp/ui/pause_menu.gd +++ b/scripts/fp/ui/pause_menu.gd @@ -54,7 +54,8 @@ const LEGEND := [ ["alert", "Incidents", "How many are active, and the newest"], ] const KEYS := [ - ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["Enter", "Chat"], + ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["B", "Build phone"], + ["Enter", "Chat"], ["[ ]", "Game speed"], ["T", "Skip to morning"], ["P", "Photo mode"], ["F1", "Hide HUD"], ["F5", "Quick save"], ["F9", "Quick load"], ] diff --git a/tools/release/PLAYING.md b/tools/release/PLAYING.md index 5047a078..2dbd0b41 100644 --- a/tools/release/PLAYING.md +++ b/tools/release/PLAYING.md @@ -19,8 +19,9 @@ The game starts at street level. To play the orbit city builder with the full HU ## Controls -- **On foot:** WASD or arrow keys to walk, Shift to run, Space to jump, Ctrl to crouch, mouse to look (click to capture the pointer), E to interact, F to get in or out of a car, M for the map, Enter to chat, V for third person, Tab for the city overview, `[` and `]` for time speed, T to skip to the next morning, Esc to pause, F1 to hide the HUD, F5 to save, F9 to load. A gamepad also works. +- **On foot:** WASD or arrow keys to walk, Shift to run, Space to jump, Ctrl to crouch, mouse to look (click to capture the pointer), E to interact, F to get in or out of a car, B for the build phone, M for the map, Enter to chat, V for third person, Tab for the city overview, `[` and `]` for time speed, T to skip to the next morning, Esc to pause, F1 to hide the HUD, F5 to save, F9 to load. A gamepad also works. - **Driving:** W or the right trigger to accelerate, S or the left trigger to brake and reverse, A/D to steer, Space for the handbrake, V for the driver's view. Slow down to get out. +- **Build phone (B):** 1-6 pick a tool (road, homes, shops, industry, a building, bulldoze), the mouse wheel picks the building, left click places it on the tile ahead (look up to reach farther), right click undoes. Roads and zones take a start and an end click. The district that owns the land pays, as if its mayor ordered it. - **Orbit builder:** WASD to pan, mouse wheel to zoom, right-drag to rotate, C to recenter, Space to pause, 1-4 for speed, F5 to save, F9 to load. ## AI mayors diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index 3d173a66..26e7ce9a 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() == 11, "all 11 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 12, "all 12 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 {} diff --git a/tools/tests/fp_phone_test.gd b/tools/tests/fp_phone_test.gd new file mode 100644 index 00000000..d1bee182 --- /dev/null +++ b/tools/tests/fp_phone_test.gd @@ -0,0 +1,314 @@ +extends SceneTree +## Phone Build app (integration feature "phone"): the preview rules, ordering roads, zones, +## buildings and bulldozing for the mayor who owns the land (paid from that district), refusals +## (another district, unclaimed land, debt), aiming, the phone UI, keys, driving and menus. + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") +const RULES := preload("res://scripts/fp/phone/build_rules.gd") + +var fails: Array[String] = [] +var ctx: FpContext +var ph: FpFeature +var toasts: Array[String] = [] + + +func _init() -> void: + await process_frame + _check_rules() + ctx = K.make_ctx(get_root(), {"view": true, "grow": true}) + ph = K.make_feature(ctx, "phone") + K.check(fails, ph != null and ph.feature_id() == "phone", "the phone feature is registered") + if ph == null: + K.finish(self, "FP_PHONE_TEST", fails) + return + ph.set_process(false) + ctx.bus.toast.connect(func(t: String, _k: String) -> void: toasts.append(t)) + var st: Dictionary = ph.self_test() + K.check(fails, bool(st.get("ok", false)), "self_test: %s" % st.get("detail", "")) + _check_open() + _check_road() + _check_other_district() + _check_refusals() + _check_zone_build_bulldoze() + _check_aim() + _check_keys() + _check_blocked() + await _check_teardown() + K.finish(self, "FP_PHONE_TEST", fails) + + +# ------------------------------------------------------------------ helpers + + +func _sim() -> CitySim: + return ctx.sim + + +## The first n free tiles in a row (along x) inside district mi, or [] when there is none. +func _free_row(mi: int, n: int, skip: Array = []) -> Array[Vector2i]: + var s := _sim() + for i in range(s.kind.size()): + var x := D.tx(i) + var y := D.ty(i) + var row: Array[Vector2i] = [] + for k in range(n): + if _free(mi, x + k, y) and not skip.has(Vector2i(x + k, y)): + row.append(Vector2i(x + k, y)) + if row.size() == n: + return row + return [] + + +func _free(mi: int, x: int, y: int) -> bool: + if not D.in_bounds(x, y): + return false + var i := D.idx(x, y) + var s := _sim() + return s.district[i] == mi and s.kind[i] == D.EMPTY and s.terrain[i] != D.WATER + + +func _money(mi: int) -> int: + return int(_sim().mayors[mi]["treasury"]) + + +## Places tool at a (and b for two-point tools) and checks that the preview matched the result +## and that district mi paid exactly the cost. +func _order(tool: String, mi: int, a: Vector2i, b: Vector2i, what: String) -> Dictionary: + ph.call("set_tool", tool) + var before := _money(mi) + if RULES.TWO_POINT.has(tool): + ph.call("place_at", a) + ph.call("refresh_at", b) + var pv: Dictionary = (ph.get("preview") as Dictionary).duplicate() + var res: Dictionary = ph.call("place_at", b) + var paid := before - _money(mi) + K.check( + fails, + bool(res.get("ok", false)) and bool(pv.get("ok", false)) and int(pv["mi"]) == mi, + "%s: placed for district %d (%s)" % [what, mi, res.get("reason", "")] + ) + K.check( + fails, + paid == int(res.get("cost", -1)) and paid == int(pv.get("cost", -2)) and paid > 0, + ( + "%s: the owner paid what the phone quoted ($%d, quoted $%d)" + % [what, paid, pv.get("cost", 0)] + ) + ) + return res + + +# ------------------------------------------------------------------ checks + + +func _check_rules() -> void: + var p := RULES.road_path(Vector2i(2, 3), Vector2i(4, 1)) + K.check( + fails, + p == [Vector2i(2, 3), Vector2i(3, 3), Vector2i(4, 3), Vector2i(4, 2), Vector2i(4, 1)], + "rules: roads run along x, then along y, like CitySim (%s)" % str(p) + ) + var long := RULES.road_path(Vector2i(0, 0), Vector2i(40, 0)) + K.check(fails, long.size() == D.MAX_ROAD_LEN, "rules: a road is capped at MAX_ROAD_LEN tiles") + K.check( + fails, RULES.BUILDINGS.size() == D.BUILDINGS.size(), "rules: every building is on the list" + ) + + +func _check_open() -> void: + K.check(fails, ph.call("open_phone") == true and bool(ph.get("open")), "open_phone() opens") + var ui: Variant = ph.get("phone") + K.check(fails, ui != null and (ui as Node).call("is_shown"), "the phone slides in") + var row := _free_row(0, 1) + ph.call("refresh_at", row[0]) + var st: Dictionary = (ui as Node).call("state") if ui != null else {} + K.check( + fails, + ( + str(st.get("district", "")) == str(D.MAYORS[0]["district"]) + and str(st.get("mayor", "")).contains(str(_sim().mayors[0]["name"])) + and int(st.get("treasury", -1)) == _money(0) + ), + "the phone shows who you build for: %s" % str(st) + ) + var ghost: Variant = ph.get("ghost") + K.check( + fails, + ghost != null and (ghost as Node3D).visible and int((ghost as Node).get("count")) == 1, + "the ghost marks the aimed tile" + ) + + +func _check_road() -> void: + var row := _free_row(0, 3) + K.check(fails, row.size() == 3, "fixture: 3 free tiles in a row in district 0") + if row.size() < 3: + return + ph.call("set_tool", "road") + var first: Dictionary = ph.call("place_at", row[0]) + K.check( + fails, + bool(first.get("pending", false)) and ph.get("anchor") == row[0], + "the first road click sets the start" + ) + ph.call("refresh_at", row[2]) + var pv: Dictionary = ph.get("preview") + K.check( + fails, + (pv["tiles"] as Array).size() == 3 and int(pv["cost"]) == 3 * D.ROAD_COST, + "the preview covers the 3 tiles for $%d (%s)" % [3 * D.ROAD_COST, str(pv)] + ) + ph.call("cancel") + K.check( + fails, + ph.get("anchor") == D.NO_TILE and bool(ph.get("open")), + "cancel drops the start, phone stays" + ) + _order("road", 0, row[0], row[2], "road") + var i := D.idx(row[1].x, row[1].y) + var s := _sim() + K.check( + fails, + s.kind[i] == D.ROAD or (s.sites.has(i) and int(s.sites[i]["target_kind"]) == D.ROAD), + "the road is built or under construction" + ) + K.check(fails, ph.get("anchor") == D.NO_TILE, "placing clears the start") + + +func _check_other_district() -> void: + var mi := 2 + var row := _free_row(mi, 2) + K.check(fails, row.size() == 2, "fixture: free land in district %d" % mi) + if row.size() < 2: + return + var mine := _money(0) + _order("road", mi, row[0], row[1], "road in another district") + K.check(fails, _money(0) == mine, "district 0 paid nothing for district %d's road" % mi) + + +func _check_refusals() -> void: + var s := _sim() + toasts.clear() + # A road from district 0 into another district stops at the line, like the sim's own rule. + var a := Vector2i(-1, -1) + var b := Vector2i(-1, -1) + for i in range(s.kind.size()): + var x := D.tx(i) + var y := D.ty(i) + if _free(0, x, y) and D.in_bounds(x + 1, y): + var j := D.idx(x + 1, y) + if s.district[j] >= 1 and s.kind[j] == D.EMPTY and s.terrain[j] != D.WATER: + a = Vector2i(x, y) + b = Vector2i(x + 1, y) + break + if a.x != -1: + ph.call("set_tool", "road") + ph.call("place_at", a) + var before := _money(0) + var res: Dictionary = ph.call("place_at", b) + K.check( + fails, + not bool(res.get("ok", true)) and _money(0) == before, + "a road into another district is refused (%s)" % str(res.get("reason", "")) + ) + ph.call("cancel") + var wild := Vector2i(-99, -99) + for i in range(s.kind.size()): + if s.district[i] < 0 and s.terrain[i] != D.WATER: + wild = Vector2i(D.tx(i), D.ty(i)) + break + K.check(fails, wild.x != -99, "fixture: unclaimed land") + ph.call("set_tool", "build") + var r2: Dictionary = ph.call("place_at", wild) + K.check( + fails, + not bool(r2.get("ok", true)) and str(r2.get("reason", "")).contains("Unclaimed"), + "unclaimed land is refused: no mayor to build for (%s)" % str(r2.get("reason", "")) + ) + K.check(fails, not toasts.is_empty(), "a refusal says why in a toast") + var spot := _free_row(1, 1) + var keep := _money(1) + s.mayors[1]["treasury"] = RULES.DEBT_FLOOR + ph.set("building", "stadium") + var r3: Dictionary = ph.call("place_at", spot[0]) + K.check( + fails, + not bool(r3.get("ok", true)) and _money(1) == RULES.DEBT_FLOOR, + "a district that cannot afford it is refused (%s)" % str(r3.get("reason", "")) + ) + s.mayors[1]["treasury"] = keep + + +func _check_zone_build_bulldoze() -> void: + var row := _free_row(1, 2) + var row2 := _free_row(1, 2, row) + if row.size() < 2 or row2.size() < 2: + K.check(fails, false, "fixture: free land in district 1") + return + _order("zone_r", 1, row[0], row[1], "zone homes") + var s := _sim() + K.check(fails, s.kind[D.idx(row[1].x, row[1].y)] == D.ZONE_R, "the tiles are zoned for homes") + ph.set("building", "park") + _order("build", 1, row2[0], row2[0], "build a park") + ph.call("cycle_building", 1) + K.check(fails, str(ph.get("building")) == RULES.BUILDINGS[1], "the wheel steps the list") + ph.call("cycle_building", -2) + K.check(fails, str(ph.get("building")) == RULES.BUILDINGS[-1], "and wraps around") + _order("bulldoze", 1, row[0], row[0], "bulldoze a zoned lot") + + +func _check_aim() -> void: + var p := ctx.player + var row := _free_row(0, 1) + p.teleport(Vector3(row[0].x, 0.0, row[0].y), 0.0) + p.update_view(1.0 / 60.0) + var t: Vector2i = ph.call("aim_tile") + var feet := Vector2(p.position.x, p.position.z) + var d := Vector2(t.x, t.y) - feet + var fwd := Vector2(p.forward().x, p.forward().z) + K.check( + fails, + d.length() > 1.2 and d.length() < 2.8 and d.dot(fwd) > 0.0, + "looking straight ahead aims about 2 tiles ahead (%s, %.1f tiles)" % [t, d.length()] + ) + var up: float = ph.call("aim_distance", deg_to_rad(30.0)) + var down: float = ph.call("aim_distance", deg_to_rad(-60.0)) + K.check( + fails, + is_equal_approx(up, 6.0) and is_equal_approx(down, 0.6), + "looking up reaches 6 tiles, looking down the next tile (%.1f, %.1f)" % [up, down] + ) + + +func _check_keys() -> void: + var has_b := false + for e in InputMap.action_get_events("phone_toggle"): + if e is InputEventKey and (e as InputEventKey).physical_keycode == KEY_B: + has_b = true + K.check(fails, has_b, "phone_toggle is bound to B") + var k := InputEventKey.new() + k.physical_keycode = KEY_3 + k.pressed = true + ph.call("_input", k) + K.check(fails, str(ph.get("tool")) == "zone_c", "key 3 picks the third tool (shops)") + + +func _check_blocked() -> void: + ctx.bus.drive_changed.emit(true) + K.check(fails, not bool(ph.get("open")), "getting into a car puts the phone away") + K.check(fails, ph.call("open_phone") == false, "no phone while driving") + ctx.bus.drive_changed.emit(false) + ctx.push_modal(&"test") + K.check(fails, ph.call("open_phone") == false, "no phone over a menu") + ctx.pop_modal(&"test") + K.check(fails, ph.call("open_phone") == true, "it opens again on foot") + ph.call("cancel") + K.check(fails, not bool(ph.get("open")), "cancel with no start closes the phone") + + +func _check_teardown() -> void: + var ui: Variant = ph.get("phone") + ph.teardown() + await process_frame + K.check(fails, ui == null or not is_instance_valid(ui), "teardown frees the phone UI") diff --git a/tools/tests/fp_phone_test.gd.uid b/tools/tests/fp_phone_test.gd.uid new file mode 100644 index 00000000..9d21ecc0 --- /dev/null +++ b/tools/tests/fp_phone_test.gd.uid @@ -0,0 +1 @@ +uid://daaciwfled61g