diff --git a/DONE.md b/DONE.md index 9445dfc..84c9ce5 100644 --- a/DONE.md +++ b/DONE.md @@ -173,6 +173,21 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every - [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested. - [x] New world/lighting headless tests (56 total). +## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback) + +- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade, + seamless concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art — all + ≤1024px and power-of-two; imported under `assets/textures/`. +- [x] Environment: textured facade + glass door, concrete sidewalk, asphalt street, striped + awning with “STEP LINE”, generated skyline backdrop planes, soft sun shadows. +- [x] NPCs: humanoid rig (torso/head/limbs) with archetype props (camera, delivery box, hat, + sign, backpack), procedural walk bob, head-turns-toward-guard cue. +- [x] UI/feedback: inspection artifact cards, mode icons (flashlight/door/wand), escalation + vignette, body-camera overlay (corner brackets + REC + shift clock). +- [x] Assets convention documented in README; `test_asset_textures_present_and_pow2` guards + the pipeline (57 tests total). +- [ ] (remaining, in TODO) crowd performance profiling and LOD/culling when the crowd grows. + ## CI — cache timeout mitigation - [x] Remove `actions/cache@v4` from the checks job (runner cannot reach GitHub's cache diff --git a/MEMORY.md b/MEMORY.md index 36f8e75..2d68a27 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -51,6 +51,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o street lamps + night mode in WorldBuilder.set_night_mode, marquee Label3D, crosswalk/planters/ trees, NPC per-seed variation. capture_screenshots.gd shoots day + fast-forwarded LATE night (6 shots in docs/screenshots/). Version bumped to 0.2.0; binaries rebuilt. +- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under + `assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run + `godot --headless --import` before tests. 57 headless tests guard the pipeline. +- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob + + attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam + overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night). - NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio easter egg); check `git status` before committing and leave foreign changes untouched unless asked. @@ -72,3 +78,5 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved) All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`. + + diff --git a/README.md b/README.md index da9f2c4..666f6d4 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The central design goal is simple: > Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is > pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are > produced with `./tools/build_releases.sh`. - +> > macOS builds are unsigned: right-click → Open, or clear the quarantine with > `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and > the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md). @@ -129,8 +129,9 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes +- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`) - **Testing:** GUT (Godot Unit Test) in headless mode -- **Source control:** git with GitHub Actions for headless tests and later desktop exports +- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **Targets:** Windows, Linux, and macOS desktop first; web later ### Setup @@ -205,8 +206,9 @@ Implemented so far: - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets +- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) -Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`). +Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, RefCounted cycle cleanup, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist diff --git a/TODO.md b/TODO.md index e718c01..4c5cfec 100644 --- a/TODO.md +++ b/TODO.md @@ -50,28 +50,28 @@ The prototype is entirely procedural `BoxMesh` with flat `StandardMaterial3D` co ### Environment -- [ ] Replace flat facade boxes with textured materials: brick/concrete facade, glass door, awning with a readable sign (generate facade + sign textures). -- [ ] Add a sidewalk concrete texture and street asphalt texture with a center-line decal (generate seamless textures). -- [ ] Add a sky: `ProceduralSkyMaterial` or a generated skybox, plus a sun with soft shadows (currently a bare `DirectionalLight3D` + flat ambient). -- [ ] Add simple props for depth: bollards, a trash can, a street lamp, a planter near the entrance (generate or model low-poly props). -- [ ] Add a night/evening lighting pass: dim ambient, warm street-lamp point lights, and a visible flashlight cone (ties into the existing `G` flashlight toggle). +- [x] Replace flat facade boxes with textured materials: brick/concrete facade, glass door, awning with a readable sign (`tools/generate_textures.py` → `assets/textures/`). +- [x] Add a sidewalk concrete texture and street asphalt texture with center dashes (seamless PNGs). +- [x] Add a sky: `ProceduralSkyMaterial` + generated skyline backdrop planes + soft sun shadows (4-split parallel). +- [x] Add props for depth: street lamps (emissive bulbs + omni lights), planters, low-poly trees (bollards/trash can still optional). +- [x] Add a night/evening lighting pass: dim ambient, warm street-lamp point lights, marquee glow, flashlight cone (`G`). ### NPCs -- [ ] Replace capsule bodies with a simple humanoid rig (head/torso/limbs) or a low-poly character mesh, keeping the archetype color coding as a fallback. -- [ ] Add per-archetype visual identity: distinct silhouettes or accessories (e.g. camera for influencers, delivery box, performer prop) instead of color-only. -- [ ] Add a simple walk/idle animation (procedural bob or imported animation) so NPCs don't glide. -- [ ] Add a facing/attention cue: NPC head turns toward the guard when `perceive_guard` reports `aware`/`near`. +- [x] Replace capsule bodies with a simple humanoid rig (torso/head/limbs) keeping the archetype color coding as the base. +- [x] Add per-archetype visual identity: camera (influencer), delivery box, performer hat, protest sign, tourist backpack. +- [x] Add a simple procedural walk animation (body bob + lean) so NPCs don't glide. +- [x] Add a facing/attention cue: NPC head turns toward the guard once `perceive_guard` raises awareness. ### UI and feedback -- [ ] Replace text-only inspection with visual artifact cards: wristband/ID/ticket images showing color, pattern, date, and event marking (generate card art). -- [ ] Add HUD icons for the tone picker, wand, flashlight, and door-lock states (generate small icons). -- [ ] Add a subtle screen-space vignette or color grade per escalation level to reinforce the existing color-coded escalation label. -- [ ] Add a body-camera frame overlay (corner timestamp + REC indicator) to sell the evidence/recording fantasy. +- [x] Replace text-only inspection with visual artifact cards (ID/wristband/ticket/credential/guest-list card art + tooltips). +- [x] Add HUD icons for wand, flashlight, and door-lock states (small white PNGs); tone-picker icon was removed with the tone system. +- [x] Add a screen-space vignette tinted per escalation level. +- [x] Add a body-camera frame overlay: corner brackets, REC indicator, and a running shift clock. ### Performance and pipeline -- [ ] Set up an `assets/` import convention (textures, materials, meshes) and document it in `README.md`. +- [x] Set up an `assets/` import convention (≤1024px, power-of-two) and document it in `README.md`; assets generated by `tools/generate_textures.py` (deterministic, PIL). - [ ] Profile the crowd: ensure textured materials and props don't hurt the target frame rate around a busy entrance. - [ ] Add LOD or culling for distant NPCs/props if the crowd grows. diff --git a/assets/textures/asphalt.png b/assets/textures/asphalt.png new file mode 100644 index 0000000..7e55622 Binary files /dev/null and b/assets/textures/asphalt.png differ diff --git a/assets/textures/awning.png b/assets/textures/awning.png new file mode 100644 index 0000000..02cd8b2 Binary files /dev/null and b/assets/textures/awning.png differ diff --git a/assets/textures/brick_facade.png b/assets/textures/brick_facade.png new file mode 100644 index 0000000..d9bf402 Binary files /dev/null and b/assets/textures/brick_facade.png differ diff --git a/assets/textures/cards/credential.png b/assets/textures/cards/credential.png new file mode 100644 index 0000000..1f5361f Binary files /dev/null and b/assets/textures/cards/credential.png differ diff --git a/assets/textures/cards/guest_list.png b/assets/textures/cards/guest_list.png new file mode 100644 index 0000000..a93c0ae Binary files /dev/null and b/assets/textures/cards/guest_list.png differ diff --git a/assets/textures/cards/id_card.png b/assets/textures/cards/id_card.png new file mode 100644 index 0000000..6f19cdd Binary files /dev/null and b/assets/textures/cards/id_card.png differ diff --git a/assets/textures/cards/ticket.png b/assets/textures/cards/ticket.png new file mode 100644 index 0000000..a9cb246 Binary files /dev/null and b/assets/textures/cards/ticket.png differ diff --git a/assets/textures/cards/wristband.png b/assets/textures/cards/wristband.png new file mode 100644 index 0000000..3726c3d Binary files /dev/null and b/assets/textures/cards/wristband.png differ diff --git a/assets/textures/concrete.png b/assets/textures/concrete.png new file mode 100644 index 0000000..e6abaf5 Binary files /dev/null and b/assets/textures/concrete.png differ diff --git a/assets/textures/icons/door_locked.png b/assets/textures/icons/door_locked.png new file mode 100644 index 0000000..cd77a59 Binary files /dev/null and b/assets/textures/icons/door_locked.png differ diff --git a/assets/textures/icons/flashlight_on.png b/assets/textures/icons/flashlight_on.png new file mode 100644 index 0000000..092e4a2 Binary files /dev/null and b/assets/textures/icons/flashlight_on.png differ diff --git a/assets/textures/icons/rec_dot.png b/assets/textures/icons/rec_dot.png new file mode 100644 index 0000000..b8355b2 Binary files /dev/null and b/assets/textures/icons/rec_dot.png differ diff --git a/assets/textures/icons/wand.png b/assets/textures/icons/wand.png new file mode 100644 index 0000000..2f3f1a0 Binary files /dev/null and b/assets/textures/icons/wand.png differ diff --git a/assets/textures/skyline.png b/assets/textures/skyline.png new file mode 100644 index 0000000..e9be6c3 Binary files /dev/null and b/assets/textures/skyline.png differ diff --git a/assets/textures/vignette.png b/assets/textures/vignette.png new file mode 100644 index 0000000..080c4e8 Binary files /dev/null and b/assets/textures/vignette.png differ diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index e242f2d..b5186f0 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index f6f9c72..491bb66 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 68677e9..79f79bf 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index fbd23ef..10c424f 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 9fbc757..870df87 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index 60a93bd..ebcb280 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index a77ba97..18d84d2 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -142,6 +142,7 @@ func _build_guard() -> GuardController: collider.shape = shape collider.position = Vector3(0, 0.85, 0) g.add_child(collider) + g.add_to_group("guard") return g func _build_scenario(id: String) -> VenueScenario: @@ -197,6 +198,10 @@ func _ensure_lighting() -> void: _sun.name = "Sun" _sun.rotation_degrees = Vector3(-50, -30, 0) _sun.light_energy = 1.2 + _sun.shadow_enabled = true + _sun.light_angular_distance = 1.2 + _sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS + _sun.directional_shadow_max_distance = 60.0 scene_root.add_child(_sun) _env_node = WorldEnvironment.new() var env := Environment.new() @@ -398,6 +403,7 @@ func _cycle_venue() -> void: switch_scenario(VENUE_ORDER[_venue_index]) var venue_name: String = selected_scenario.display_name if selected_scenario else "?" hud.log(Strings.fmt("hud.venue_switched", [venue_name])) + hud.set_wand_icon(false) hud.set_shift_state(coordinator, selected_scenario) ## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players @@ -548,11 +554,14 @@ func _toggle_inspection() -> void: func _toggle_wand_screening() -> void: if wand_open: hud.hide_wand() + hud.set_wand_icon(false) wand_open = false return if not selected_scenario.equipment.has("wand"): hud.log(Strings.lookup("hud.no_wand")) + hud.set_wand_icon(false) return + hud.set_wand_icon(true) var controller: Object = _targeted_controller() if controller == null: return diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 9f70187..c59fc5e 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -68,41 +68,136 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector add_child(collider) var _visual_seed := 0 +var _rig: Node3D = null +var _head: Node3D = null +var _bob_phase := 0.0 +var _guard_ref: Node3D = null +const SKIN_TONE := Color(0.88, 0.72, 0.60) +const PANTS_COLOR := Color(0.22, 0.22, 0.26) func _build_body(npc_type: int) -> void: - var body := MeshInstance3D.new() - var capsule := CapsuleMesh.new() - capsule.radius = 0.35 - capsule.height = 1.6 - body.mesh = capsule - var mat := StandardMaterial3D.new() - mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) + _rig = Node3D.new() + _rig.name = "Rig" + add_child(_rig) + var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7)) # Per-seed variation: slight height and brightness differences so crowds # read as individuals (deterministic LCG, no RNG side effects). var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0 - var height_scale := 0.9 + 0.2 * frac - body.scale = Vector3(1.0, height_scale, 1.0) - body.position.y = (height_scale - 1.0) * 0.8 - mat.albedo_color = mat.albedo_color.lightened(0.15 * frac) if frac < 0.5 else mat.albedo_color.darkened(0.15 * (1.0 - frac)) - body.material_override = mat - add_child(body) - var id_tag := MeshInstance3D.new() - var sphere := SphereMesh.new() - sphere.radius = 0.12 - sphere.height = 0.24 - var tag_mat := StandardMaterial3D.new() - tag_mat.albedo_color = Color(1, 1, 1) - id_tag.mesh = sphere - id_tag.material_override = tag_mat - id_tag.position = Vector3(0, 1.05, 0) - id_tag.scale = Vector3(1, 0.4, 1) - add_child(id_tag) + var height_scale := 0.92 + 0.16 * frac + _rig.scale.y = height_scale + var shader_delta := 0.12 * frac + base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta) + var limbs_color := base_color.darkened(0.25) + # Torso + _add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color) + # Legs + _add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR) + _add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR) + # Arms + _add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) + _add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) + # Head + _head = Node3D.new() + _head.name = "Head" + _head.position = Vector3(0, 1.62, 0) + _add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE) + _rig.add_child(_head) + _add_props(npc_type) + +func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void: + var mi := MeshInstance3D.new() + var mesh := CapsuleMesh.new() + mesh.radius = radius + mesh.height = height + mi.mesh = mesh + mi.position = pos + mi.rotation_degrees.z = z_rot + mi.material_override = _material(color) + parent.add_child(mi) + +func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void: + var mi := MeshInstance3D.new() + var mesh := SphereMesh.new() + mesh.radius = radius + mesh.height = radius * 2.0 + mi.mesh = mesh + mi.position = pos + mi.material_override = _material(color) + parent.add_child(mi) + +func _material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.albedo_color = color + return mat + +## Small per-archetype props so silhouettes differ (camera, box, hat, sign…). +func _add_props(npc_type: int) -> void: + var chest_y := 1.25 + match npc_type: + Rules.NpcType.INFLUENCER: + _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1)) + _add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0)) + Rules.NpcType.DELIVERY: + _add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25)) + Rules.NpcType.PERFORMER: + var hat := _material(Color(0.85, 0.2, 0.25)) + var cone := MeshInstance3D.new() + var cm := CylinderMesh.new() + cm.top_radius = 0.02 + cm.bottom_radius = 0.22 + cm.height = 0.4 + cone.mesh = cm + cone.material_override = hat + cone.position = Vector3(0, 0.26, 0) + _head.add_child(cone) + Rules.NpcType.PROTESTER: + _add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32)) + _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95)) + Rules.NpcType.TOURIST: + _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45)) + Rules.NpcType.CONFUSED_VISITOR: + _add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8)) + +func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void: + var mi := MeshInstance3D.new() + var mesh := BoxMesh.new() + mesh.size = size + mi.mesh = mesh + mi.position = pos + mi.material_override = _material(color) + parent.add_child(mi) func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return _advance_fsm(delta) _move_toward_target(delta) + _animate(delta) + +## Procedural walk bob + head-toward-guard cue when the NPC is aware. +func _animate(delta: float) -> void: + if _rig == null: + return + var speed := Vector2(velocity.x, velocity.z).length() + if speed > 0.3: + _bob_phase += delta * (6.0 + speed) + _rig.position.y = sin(_bob_phase) * 0.035 + _rig.rotation.x = sin(_bob_phase * 0.5) * 0.04 + else: + _bob_phase = 0.0 + _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) + _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) + # Attention cue: head turns toward the guard when awareness was raised. + if _head != null and npc.guard_noticed: + if _guard_ref == null or not is_instance_valid(_guard_ref): + _guard_ref = get_tree().get_first_node_in_group("guard") as Node3D + if _guard_ref: + var to_guard := (_guard_ref.global_position - global_position) + to_guard.y = 0.0 + var world_yaw := atan2(to_guard.x, to_guard.z) + var fwd := -_rig.global_transform.basis.z + var parent_yaw := atan2(fwd.x, fwd.z) + _head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) func _advance_fsm(delta: float) -> void: # Returning-trespasser: an NPC directed away may circle back to the door. diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 54572b4..96156d2 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -5,6 +5,23 @@ extends CanvasLayer const Rules := preload("res://src/core/rules.gd") const Strings := preload("res://src/core/strings.gd") +const T_VIGNETTE := preload("res://assets/textures/vignette.png") +const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png") +const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png") +const T_ICON_WAND := preload("res://assets/textures/icons/wand.png") +const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png") +const T_CARD_ID := preload("res://assets/textures/cards/id_card.png") +const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png") +const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png") +const T_CARD_CRED := preload("res://assets/textures/cards/credential.png") +const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png") +const CARD_TEXTURES := { + Rules.AccessKind.ID: T_CARD_ID, + Rules.AccessKind.WRISTBAND: T_CARD_WRIST, + Rules.AccessKind.TICKET: T_CARD_TICKET, + Rules.AccessKind.CREDENTIAL: T_CARD_CRED, + Rules.AccessKind.GUEST_LIST: T_CARD_GUEST, +} var top_label: Label var time_label: Label @@ -17,6 +34,12 @@ var inspect_text: Label var inspect_meta: Label var radio_cooldown_label: Label var mode_label: Label +var vignette_rect: TextureRect +var cam_clock: Label +var icon_row: HBoxContainer +var flashlight_icon: TextureRect +var door_icon: TextureRect +var wand_icon: TextureRect var wand_panel: PanelContainer var wand_text: Label var wand_meta: Label @@ -105,6 +128,57 @@ func _init() -> void: escalation_label.modulate = Color(0.6, 0.9, 0.6) vbox.add_child(escalation_label) + # --- escalation vignette (screen-space tint) --- + vignette_rect = TextureRect.new() + vignette_rect.texture = T_VIGNETTE + vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT) + vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + vignette_rect.modulate = Color(1, 1, 1, 0) + vignette_rect.show_behind_parent = false + add_child(vignette_rect) + + # --- body-camera overlay (REC + shift clock + corner brackets, top right) --- + var cam_box := VBoxContainer.new() + cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT) + cam_box.position = Vector2(-180, 12) + cam_box.alignment = BoxContainer.ALIGNMENT_END + add_child(cam_box) + var rec_row := HBoxContainer.new() + rec_row.alignment = BoxContainer.ALIGNMENT_END + cam_box.add_child(rec_row) + var rec_icon := TextureRect.new() + rec_icon.texture = T_ICON_REC + rec_icon.custom_minimum_size = Vector2(22, 22) + rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + rec_row.add_child(rec_icon) + var rec_label := Label.new() + rec_label.text = "REC" + rec_label.add_theme_font_size_override("font_size", 15) + rec_label.modulate = Color(0.95, 0.35, 0.35) + rec_row.add_child(rec_label) + cam_clock = Label.new() + cam_clock.text = "SHIFT 00:00:00" + cam_clock.add_theme_font_size_override("font_size", 12) + cam_clock.modulate = Color(0.95, 0.95, 0.95) + cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + cam_box.add_child(cam_clock) + # corner brackets + var bracket_color := Color(0.9, 0.9, 0.92, 0.55) + _add_corner_brackets(bracket_color) + + # --- mode icons next to the door/light label --- + icon_row = HBoxContainer.new() + vbox.add_child(icon_row) + flashlight_icon = _make_icon(T_ICON_FLASH) + flashlight_icon.visible = false + icon_row.add_child(flashlight_icon) + door_icon = _make_icon(T_ICON_DOOR) + door_icon.visible = false + icon_row.add_child(door_icon) + wand_icon = _make_icon(T_ICON_WAND) + wand_icon.visible = false + icon_row.add_child(wand_icon) + # --- inspect panel (popup center) --- inspect_panel = PanelContainer.new() inspect_panel.visible = false @@ -163,6 +237,9 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void: coordinator.reputation.rating, coordinator.crowd.mood * 100.0, ] + if cam_clock: + var total := int(coordinator.elapsed_s) + cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60] _update_radio_cooldown(coordinator) func _update_radio_cooldown(coordinator: Object) -> void: @@ -188,6 +265,47 @@ func set_flashlight(on: bool) -> void: func _refresh_mode_label() -> void: mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"] + if icon_row: + flashlight_icon.visible = _flashlight + door_icon.visible = _door_locked + +func set_wand_icon(visible: bool) -> void: + if wand_icon: + wand_icon.visible = visible + +func _make_icon(texture: Texture2D) -> TextureRect: + var tr := TextureRect.new() + tr.texture = texture + tr.custom_minimum_size = Vector2(20, 20) + tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + tr.modulate = Color(1, 1, 1, 0.9) + return tr + +func _add_corner_brackets(color: Color) -> void: + const LEN := 34.0 + const TH := 3.0 + const OFF := 10.0 + var viewport := get_viewport() + var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720) + var corners := [ + Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF), + ] + for i in 4: + var c: Vector2 = corners[i] + var flip_x: bool = (i % 2) == 1 + var flip_y: bool = i >= 2 + var h := ColorRect.new() + h.color = color + h.size = Vector2(LEN, TH) + h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0)) + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(h) + var v := ColorRect.new() + v.color = color + v.size = Vector2(TH, LEN) + v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0)) + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(v) func set_action_hint(text: String) -> void: hint_label.text = text @@ -199,15 +317,20 @@ func set_action_hint(text: String) -> void: func set_escalation(level: int) -> void: var name: String = Rules.LEVEL_NAMES.get(level, "Observation") escalation_label.text = Strings.fmt("hud.escalation_prefix", [name]) + var tint := Color(0.6, 0.9, 0.6) match level: Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE: - escalation_label.modulate = Color(0.6, 0.9, 0.6) + tint = Color(0.6, 0.9, 0.6) Rules.Level.FORMAL_WARNING: - escalation_label.modulate = Color(0.95, 0.9, 0.4) + tint = Color(0.95, 0.9, 0.4) Rules.Level.BLOCKING: - escalation_label.modulate = Color(1.0, 0.65, 0.3) + tint = Color(1.0, 0.65, 0.3) Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY: - escalation_label.modulate = Color(1.0, 0.35, 0.3) + tint = Color(1.0, 0.35, 0.3) + escalation_label.modulate = tint + # Screen-space vignette follows the escalation color. + if vignette_rect: + vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0) func log(text: String) -> void: _log_lines.append(text) @@ -240,6 +363,26 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) - var npc_display: String = (profile.display_name if profile else "Person") var granted: bool = result.get("granted", false) inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"] + _clear_card_row() + var card_row := HBoxContainer.new() + card_row.name = "INSPECT_CARD_ROW" + card_row.add_theme_constant_override("separation", 6) + var added_card := false + for artifact in artifacts: + var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D + if tex == null: + continue + var card := TextureRect.new() + card.texture = tex + card.custom_minimum_size = Vector2(92, 58) + card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + card.tooltip_text = artifact.summary() + if artifact.attributes.size() > 0: + card.tooltip_text += "\n" + str(artifact.attributes) + card_row.add_child(card) + added_card = true + if added_card: + inspect_panel.add_child(card_row) var lines: Array[String] = [] for artifact in artifacts: lines.append(" • " + artifact.summary()) @@ -251,8 +394,18 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) - inspect_panel.visible = true func hide_inspection() -> void: + _clear_card_row() inspect_panel.visible = false +## Remove previously added artifact cards so reopening rebuilds them. +func _clear_card_row() -> void: + if inspect_panel == null: + return + var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control + if row: + inspect_panel.remove_child(row) + row.queue_free() + func show_wand(controller: Object, result: Dictionary) -> void: var profile: Object = controller.profile var npc_display: String = (profile.display_name if profile else "Person") diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 11df3fd..a204271 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -1,5 +1,11 @@ class_name WorldBuilder ## Procedurally assembles a minimal Hollywood Boulevard entrance prototype: + +const T_BRICK := preload("res://assets/textures/brick_facade.png") +const T_CONCRETE := preload("res://assets/textures/concrete.png") +const T_ASPHALT := preload("res://assets/textures/asphalt.png") +const T_AWNING := preload("res://assets/textures/awning.png") +const T_SKYLINE := preload("res://assets/textures/skyline.png") ## steps, building facade, restricted door, sidewalk, pavement, and street edge. ## No art assets required; all geometry is BoxMesh with flat materials. @@ -27,14 +33,19 @@ func build() -> void: _build_crosswalk() _build_street_furniture() _build_guard_spawn() + _build_skyline_backdrop() _build_navmesh() set_night_mode(false) -func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D: +func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false, + texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D: var mesh := BoxMesh.new() mesh.size = size var mat := StandardMaterial3D.new() mat.albedo_color = color + if texture: + mat.albedo_texture = texture + mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0) mesh.material = mat var mi := MeshInstance3D.new() mi.mesh = mesh @@ -56,19 +67,23 @@ func _build_ground() -> void: func _build_building() -> void: var building := StaticBody3D.new() building.name = "Building" - # Main facade - _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30)) + # Main facade (textured brick) + _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5)) # Entrance recess + door recess frame - _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10)) - # Door - _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13)) - # Door window - _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0)) + _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09)) + # Door frame and dark door + _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11)) + # Door glass (semi-transparent) + var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55)) + var glass_mat := StandardMaterial3D.new() + glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45) + glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + glass_mat.roughness = 0.15 + glass_mat.metallic = 0.1 + glass.material_override = glass_mat # Door lock indicator (green = open, red = locked), placed beside the handle door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) - # Awning sign bar - _add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44)) var col := BoxShape3D.new() col.size = Vector3(40, 18, 4) var so := building.create_shape_owner(building) @@ -96,7 +111,7 @@ func _build_steps() -> void: func _build_sidewalk() -> void: var sidewalk := StaticBody3D.new() sidewalk.name = "Sidewalk" - _add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56)) + _add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4)) var col := BoxShape3D.new() col.size = Vector3(30, 0.36, 8) var so := sidewalk.create_shape_owner(sidewalk) @@ -107,7 +122,7 @@ func _build_sidewalk() -> void: func _build_street() -> void: var street := StaticBody3D.new() street.name = "Street" - _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14)) + _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7)) # Center dashes for i in range(-40, 41, 4): _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30)) @@ -138,6 +153,9 @@ func _build_navmesh() -> void: root.add_child(region) func _build_marquee() -> void: + ## Striped awning above the door, then the neon sign strip + label. + var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING) + awning.name = "Awning" ## Neon-style marquee strip above the door: emissive bars + a label. var strip_parent := Node3D.new() strip_parent.name = "Marquee" @@ -243,6 +261,28 @@ func _plain_material(color: Color) -> StandardMaterial3D: mat.albedo_color = color return mat +func _build_skyline_backdrop() -> void: + ## Distant procedural skyline on four unshaded planes around the block. + var mat := StandardMaterial3D.new() + mat.albedo_texture = T_SKYLINE + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + const DIST := 95.0 + const HEIGHT := 90.0 + for i in 4: + var plane := MeshInstance3D.new() + plane.name = "Skyline_%d" % i + var pm := PlaneMesh.new() + pm.size = Vector2(300, HEIGHT) + plane.mesh = pm + plane.material_override = mat + var angle := i * 90.0 + plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST) + plane.rotation_degrees = Vector3(0, -angle + 90.0, 0) + plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting) + root.add_child(plane) + func _build_guard_spawn() -> void: var marker := Marker3D.new() marker.name = "GuardSpawn" diff --git a/src/test/tests/test_world.gd b/src/test/tests/test_world.gd index 42f6839..d520d9c 100644 --- a/src/test/tests/test_world.gd +++ b/src/test/tests/test_world.gd @@ -45,6 +45,39 @@ func test_street_lamps_and_marquee_built() -> bool: root.free() return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight") +func test_asset_textures_present_and_pow2() -> bool: + var required := [ + "res://assets/textures/brick_facade.png", + "res://assets/textures/concrete.png", + "res://assets/textures/asphalt.png", + "res://assets/textures/awning.png", + "res://assets/textures/skyline.png", + "res://assets/textures/vignette.png", + "res://assets/textures/icons/flashlight_on.png", + "res://assets/textures/icons/door_locked.png", + "res://assets/textures/icons/wand.png", + "res://assets/textures/icons/rec_dot.png", + "res://assets/textures/cards/id_card.png", + "res://assets/textures/cards/wristband.png", + "res://assets/textures/cards/ticket.png", + "res://assets/textures/cards/credential.png", + "res://assets/textures/cards/guest_list.png", + ] + for path in required: + if not FileAccess.file_exists(path): + return TestUtils.fail("missing generated asset: %s — run tools/generate_textures.py" % path) + var img := Image.new() + for path in required: + if img.load(path) != OK: + return TestUtils.fail("cannot decode asset: %s" % path) + var w := img.get_width() + var h := img.get_height() + if w > 1024 or h > 1024: + return TestUtils.fail("asset too large: %s (%dx%d)" % [path, w, h]) + if (w & (w - 1)) != 0 or (h & (h - 1)) != 0: + return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h]) + return true + func test_door_indicator_built() -> bool: var root := Node3D.new() var builder := WorldBuilder.new(root) diff --git a/tools/generate_textures.py b/tools/generate_textures.py new file mode 100644 index 0000000..48b1a9f --- /dev/null +++ b/tools/generate_textures.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Generate small authored textures for Step Line (assets/ import convention). + +All outputs are deterministic (fixed seeds), <=1024px, power-of-two where +possible. Run from the repo root: python3 tools/generate_textures.py +""" + +import math +import os +import random +import stat +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +ASSETS = os.path.join(ROOT, "assets", "textures") + +FONT_CANDIDATES = [ + "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf", + "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", +] + +def font(size: int): + for path in FONT_CANDIDATES: + if os.path.exists(path): + return ImageFont.truetype(path, size) + return ImageFont.load_default() + +def save(img: Image.Image, name: str) -> None: + os.makedirs(ASSETS, exist_ok=True) + path = os.path.join(ASSETS, name) + os.makedirs(os.path.dirname(path), exist_ok=True) + img.save(path, optimize=True) + print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") + +def grain(size, base, spread, seed, blur=1.0): + rnd = random.Random(seed) + img = Image.new("L", (size, size), base) + px = img.load() + for y in range(size): + for x in range(size): + v = base + rnd.randint(-spread, spread) + px[x, y] = max(0, min(255, v)) + return img.filter(ImageFilter.GaussianBlur(blur)) + +def brick_facade(): + size = 512 + mortar = (118, 110, 104) + base = (150, 92, 78) + img = Image.new("RGB", (size, size), mortar) + d = ImageDraw.Draw(img) + rnd = random.Random(11) + brick_h = size // 22 + mortar_t = 5 + offset = 0 + for row in range(22): + y = row * brick_h + cols = 2 if row % 2 == 0 else 3 + brick_w = (size - mortar_t) // (cols if cols > 1 else 1) + for c in range(cols + 1): + x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0) + x1 = x0 + brick_w + # wrap horizontally so the tile repeats seamlessly + d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base) + # per-brick shading pass + for _ in range(size * size // 900): + bx = rnd.randint(0, size - 1) + by = rnd.randint(0, size - 1) + d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), + max(0, base[1] - 10 + rnd.randint(0, 22)), + max(0, base[2] - 8 + rnd.randint(0, 18)))) + # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) + for y in [-mortar_t, size - mortar_t]: + d.rectangle([0, y, size, y + mortar_t], fill=mortar) + save(img, "brick_facade.png") + +def seamless_concrete(): + size = 512 + img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") + d = ImageDraw.Draw(img) + rnd = random.Random(19) + for _ in range(90): + x, y = rnd.randint(0, size), rnd.randint(0, size) + r = rnd.choice([2, 3, 4]) + d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) + for _ in range(8): # subtle crack segments + x, y = rnd.randint(0, size), rnd.randint(0, size) + pts = [(x, y)] + for _ in range(6): + x += rnd.randint(-24, 24) + y += rnd.randint(-8, 8) + pts.append((x % size, y % size)) + d.line(pts, fill=(70, 70, 70, 170), width=1) + save(img, "concrete.png") + +def asphalt(): + size = 512 + img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") + d = ImageDraw.Draw(img) + rnd = random.Random(31) + for _ in range(600): + x, y = rnd.randint(0, size), rnd.randint(0, size) + d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) + save(img, "asphalt.png") + +def awning(): + w, h = 1024, 256 + img = Image.new("RGB", (w, h), (140, 40, 45)) + d = ImageDraw.Draw(img) + stripe = 64 + for i in range(w // stripe): + if i % 2 == 1: + d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) + # scalloped bottom edge + for x in range(0, w, 32): + d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) + f = font(96) + tw = d.textlength("STEP LINE", font=f) + d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235)) + # subtle top shading + shade = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + sd = ImageDraw.Draw(shade) + sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90)) + img = Image.alpha_composite(img.convert("RGBA"), shade) + save(img, "awning.png") + +def skyline(): + w, h = 1024, 256 + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + rnd = random.Random(41) + build_color = (16, 18, 30, 255) + x = -40 + while x < w + 40: + bw = rnd.randint(64, 170) + bh = rnd.randint(150, 400) + d.rectangle([x, h - bh, x + bw, h], fill=build_color) + # few lit windows + for _ in range(bw * bh // 900): + wx = x + rnd.randint(4, max(4, bw - 8)) + wy = h - rnd.randint(4, bh - 4) + if rnd.random() < 0.5: + d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) + x += bw + rnd.randint(10, 46) + d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) + save(img, "skyline.png") + +def vignette(): + size = 512 + img = Image.new("L", (size, size), 0) + px = img.load() + c = size / 2.0 + max_d = math.hypot(c, c) + for y in range(size): + for x in range(size): + dist = math.hypot(x - c, y - c) / max_d + t = max(0.0, (dist - 0.55) / 0.45) + px[x, y] = int(255 * (t * t)) + save(img, "vignette.png") + +def icon(name, draw_fn): + size = 64 + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + draw_fn(d, size) + save(img, name) + +def icons(): + def flashlight_on(d, s): + d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255)) + d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255)) + def door_locked(d, s): + d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5) + d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) + d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5) + def wand(d, s): + d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255)) + d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) + def rec(d, s): + d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5) + d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) + icon("icons/flashlight_on.png", flashlight_on) + icon("icons/door_locked.png", door_locked) + icon("icons/wand.png", wand) + icon("icons/rec_dot.png", rec) + +def cards(): + CW, CH = 256, 128 + def base(card_name, bg, label, band=(0,0,0,0)): + img = Image.new("RGBA", (CW, CH), bg + (255,)) + d = ImageDraw.Draw(img) + d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) + if band and band[3] == 255: + d.rectangle([0, CH-34, CW, CH], fill=band) + f = font(26) + d.text((14, 10), label, font=f, fill=(255,255,255,235)) + # body text lines + f2 = font(15) + y = 52 + for _ in range(4): + w = 150 - (24 if _ == 3 else 0) + d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) + y += 24 + # photo square for id-like cards + if "photo" in card_name: + d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) + save(img, f"cards/{card_name}.png") + base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) + base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) + base("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 255)) + base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) + base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) + +if __name__ == "__main__": + brick_facade() + seamless_concrete() + asphalt() + awning() + skyline() + vignette() + icons() + cards() + print("done")