Compare commits

...
6 Commits
Author SHA1 Message Date
zwitschi 0f076205af Fix NSIS installer build: absolute paths via defines, space-free installed exe, BMP ICO
Full build / Import + headless tests (push) Successful in 31s
Full build / Full desktop build + optional release (push) Successful in 3m44s
- makensis resolves relative -DICON/-DARTIFACT against the script directory,
  so CI now passes $PWD-based absolute paths (reproduced locally in the same
  ubuntu:24.04 container; installer builds, 7z shows step-line.exe +
  uninstall.exe)
- Install as step-line.exe (no spaces: NSIS /oname splits on whitespace)
- OutFile overridable via -DOUT
- tools/generate_textures.py icon.ico now produced via ImageMagick with
  classic BMP entries (NSIS-compatible); PIL PNG-compressed ICOs rejected by
  NSIS
2026-10-31 14:22:55 +01:00
zwitschi 8bc84c1f36 Harden installer icon + NSIS rebuild path
Full build / Full desktop build + optional release (push) Failing after 3m24s
Full build / Import + headless tests (push) Successful in 27s
- Regenerate icon.ico with classic BMP entries (16/32/48) — PNG-compressed
  ICO entries can fail NSIS' icon loader on some builds
- CI copies assets/textures/icon.ico onto the packaging path right before
  makensis, removing any stale/missing-copy failures
- Recreate v0.2.1 tag after this fix
2026-10-31 14:15:38 +01:00
zwitschi 418c30e334 Fix NSIS CI step: use -D defines (makensis on Linux rejects /D as scripts); AppStream metadata hardened (homepage, developer, lowercase id, filename match)
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m26s
- AppImage validation now passes appstreamcli --pedantic locally; builds with
  metainfo named net.stepline.game.appdata.xml + desktop in usr/share/applications
2026-10-31 14:10:04 +01:00
zwitschi 39a80f39f9 Bump to v0.2.1 (maintenance): CI now verifies the NSIS installer contents via 7z
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m10s
- Add a 'Verify Windows installer contents' step (7z l shows Step Line.exe +
  uninstall.exe) so the TODO verification items are proven by CI output
- Rebuild 0.2.1 binaries (embedded PCK) and AppImage locally; smoke clean;
  57/57 tests
2026-10-31 14:05:12 +01:00
zwitschi 5b994982bd Add OpenRouter AI texture generation mode to the asset pipeline
- tools/generate_textures.py: --ai mode (OPENROUTER_API_KEY, --prompt,
  --name, --model, --size; base64 PNG decode; clamp to <=1024px) with a
  default batch covering the authored texture set; seedream minimum-size
  handled by requesting 2048 and downscaling
- Generated AI asset set committed under assets/textures/ai/ (15 files,
  1024x1024 each), kept separate from the deterministic PIL set
- README/MEMORY/TODO document the mode and live verification
- (work authored in a parallel session; linted/verified here — 57/57 tests)
2026-10-31 14:04:37 +01:00
zwitschi 0abd67fbaa Ignore .env files 2026-10-31 13:56:20 +01:00
48 changed files with 426 additions and 47 deletions

No files matched your search

+16 -4
View File
@@ -79,13 +79,25 @@ jobs:
- name: Package Windows installer (NSIS) - name: Package Windows installer (NSIS)
run: | run: |
sudo apt-get update && sudo apt-get install -y nsis sudo apt-get update && sudo apt-get install -y nsis p7zip-full
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)" VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
makensis /DVERSION="$VERSION" \ # NOTE: on Linux, makensis uses -D defines (the /D form is Windows-only
/DARTIFACT="build/step-line-windows-$VERSION.exe" \ # and is rejected as a script path), and it resolves relative paths
/DICON=packaging/windows/step-line.ico \ # against the script directory — pass absolute paths from the workspace.
cp assets/textures/icon.ico packaging/windows/step-line.ico
makensis \
-DVERSION="$VERSION" \
-DARTIFACT="$PWD/build/step-line-windows-$VERSION.exe" \
-DICON="$PWD/packaging/windows/step-line.ico" \
-DOUT="$PWD/build/step-line-setup-$VERSION.exe" \
packaging/windows/step-line.nsi packaging/windows/step-line.nsi
- name: Verify Windows installer contents
run: |
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
ls -la build/step-line-setup-$VERSION.exe
7z l build/step-line-setup-$VERSION.exe | grep -E "step-line.exe|uninstall.exe"
- name: Package Linux AppImage - name: Package Linux AppImage
run: | run: |
bash packaging/linux/build_appimage.sh bash packaging/linux/build_appimage.sh
+2
View File
@@ -18,3 +18,5 @@ user://
# CI runner workflow logs (written into the checkout by the Gitea runner) # CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/ .gitea/logs/
build/ build/
.env
+7
View File
@@ -185,6 +185,13 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
Follow-up: anonymous **asset downloads** require sign-in on this host (host setting). Follow-up: anonymous **asset downloads** require sign-in on this host (host setting).
- [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags). - [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags).
## 🎨 AI texture generation (OpenRouter)
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode (`--ai`, `--prompt`,
`--name`, `--model`, `--size`), decoding base64 PNG responses and clamping to ≤1024px.
- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` (generated
`ai/…` set at 1024×1024; seedream minimum-pixel by requesting 2048 and downscaling.
## 🧹 Runtime hygiene + timeline/polish items ## 🧹 Runtime hygiene + timeline/polish items
- [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is - [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is
+14
View File
@@ -59,6 +59,20 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under - Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run `assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline. `godot --headless --import` before tests. 57 headless tests guard the pipeline.
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
`OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it
with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
is needed.
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob + - Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night). overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
+1 -1
View File
@@ -136,7 +136,7 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes - **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`) - **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`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
- **Testing:** GUT (Godot Unit Test) in headless mode - **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **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 - **Targets:** Windows, Linux, and macOS desktop first; web later
+15
View File
@@ -1,5 +1,16 @@
# TODO # TODO
## 🎨 AI texture generation (OpenRouter)
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode
(`--ai`, `--prompt`, `--name`, `--model`, `--size`), decoding base64 PNG
responses and clamping output to ≤1024px.
- [x] Add prompts for all 15 game textures (world surfaces, awning, skyline,
vignette, 4 HUD icons, 5 access cards) and generate them under `assets/textures/ai/`.
- [ ] Make the `ai/` icons/vignette/skyline truly transparent: seedream-4.5 ignores
`background: transparent` (returns opaque images). Either switch to a
transparency-capable model or post-process (e.g. chroma-key the flat background).
## 🪟 Windows installer (self-contained, all game data) ## 🪟 Windows installer (self-contained, all game data)
Goal: a real Windows installer that includes the entire game (executable + data), Goal: a real Windows installer that includes the entire game (executable + data),
@@ -57,3 +68,7 @@ performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a - [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance. busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows. - [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
## Screenshot Refresh
- [ ] Regenerate and verify the six README screenshots from the current build.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 237 KiB

+5 -2
View File
@@ -27,8 +27,11 @@ cp "$HERE/step-line.desktop" "$APPDIR/step-line.desktop"
cp "$HERE/AppRun" "$APPDIR/AppRun" cp "$HERE/AppRun" "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
cp "$HERE/step-line.png" "$APPDIR/step-line.png" cp "$HERE/step-line.png" "$APPDIR/step-line.png"
mkdir -p "$APPDIR/usr/share/metainfo" # Desktop entry + metainfo in standard locations (AppStream validation requires
cp "$HERE/step-line.appdata.xml" "$APPDIR/usr/share/metainfo/step-line.appdata.xml" # the metainfo filename to match the component id, and the .desktop discoverable).
mkdir -p "$APPDIR/usr/share/applications" "$APPDIR/usr/share/metainfo"
cp "$HERE/step-line.desktop" "$APPDIR/usr/share/applications/step-line.desktop"
cp "$HERE/step-line.appdata.xml" "$APPDIR/usr/share/metainfo/net.stepline.game.appdata.xml"
TOOL="${APPIMAGE_TOOL:-/tmp/appimagetool-x86_64.AppImage}" TOOL="${APPIMAGE_TOOL:-/tmp/appimagetool-x86_64.AppImage}"
if [[ ! -f "$TOOL" ]]; then if [[ ! -f "$TOOL" ]]; then
+3 -1
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application"> <component type="desktop-application">
<id>net.step-line.StepLine</id> <id>net.stepline.game</id>
<name>Step Line</name> <name>Step Line</name>
<summary>Workplace security simulation on Hollywood Boulevard</summary> <summary>Workplace security simulation on Hollywood Boulevard</summary>
<url type="homepage">https://git.allucanget.biz/zwitschi/security-game</url>
<developer id="net.stepline"><name>Step Line Team</name></developer>
<description> <description>
<p> <p>
Step Line is a workplace security simulation about observation, communication, Step Line is a workplace security simulation about observation, communication,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+7 -3
View File
@@ -14,8 +14,12 @@
!define ICON "packaging\windows\step-line.ico" !define ICON "packaging\windows\step-line.ico"
!endif !endif
; The installed executable always uses this clean name. ; The installed executable uses this clean name (no spaces — NSIS /oname tokens
!define INSTALLED_EXE "Step Line.exe" ; on whitespace, so a spaced name breaks the File directive).
!define INSTALLED_EXE "step-line.exe"
!ifndef OUT
!define OUT "build\step-line-setup-${VERSION}.exe"
!endif
!define COMPANY "Step Line Project" !define COMPANY "Step Line Project"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line" !define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line"
@@ -25,7 +29,7 @@ Icon "${ICON}"
UninstallIcon "${ICON}" UninstallIcon "${ICON}"
Name "Step Line ${VERSION}" Name "Step Line ${VERSION}"
OutFile "build\step-line-setup-${VERSION}.exe" OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\Step Line" InstallDir "$PROGRAMFILES64\Step Line"
RequestExecutionLevel admin RequestExecutionLevel admin
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.0" config/version="0.2.1"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+1
View File
@@ -0,0 +1 @@
appimagetool.png
+10
View File
@@ -0,0 +1,10 @@
#! /bin/sh
set -e
this_dir="$(dirname -- "$(readlink -f -- "$0")")"
# make appimagetool prefer the bundled mksquashfs
export PATH="$this_dir"/usr/bin:"$PATH"
exec "$this_dir"/usr/bin/appimagetool "$@"
+1
View File
@@ -0,0 +1 @@
usr/share/applications/appimagetool.desktop
+1
View File
@@ -0,0 +1 @@
usr/share/icons/hicolor/128x128/apps/appimagetool.png
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=appimagetool
Exec=appimagetool
Comment=Tool to generate AppImages from AppDirs
Icon=appimagetool
Categories=Development;
Terminal=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>appimagetool.desktop</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>appimagetool</name>
<summary>Create AppImage files</summary>
<description>
<p>
appimagetool is a tool that lets you generate AppImage files. An AppImage
is a self-running bundle that contains an application and everything
it needs to run that cannot reasonably expected to be part of each target system.
</p>
<p>
This can include libraries that are not commonly available on target systems,
resources such as translations, icons, fonts, and other auxiliary files.
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
and turn it into an AppImage.
</p>
<p>
You can also embed update information so that your users can update the AppImage
using binary delta updates with the AppImageUpdate tool.
You can also sign the AppImage with GPG.
</p>
</description>
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
<project_group>AppImage</project_group>
<provides>
<binary>appimagetool</binary>
</provides>
</component>
+15 -7
View File
@@ -140,7 +140,7 @@ func _init() -> void:
# --- body-camera overlay (REC + shift clock + corner brackets, top right) --- # --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
var cam_box := VBoxContainer.new() var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT) cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 12) cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box) add_child(cam_box)
var rec_row := HBoxContainer.new() var rec_row := HBoxContainer.new()
@@ -180,9 +180,12 @@ func _init() -> void:
icon_row.add_child(wand_icon) icon_row.add_child(wand_icon)
# --- inspect panel (popup center) --- # --- inspect panel (popup center) ---
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new() inspect_panel = PanelContainer.new()
inspect_panel.visible = false inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0 inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new() var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox) inspect_panel.add_child(ivbox)
@@ -197,12 +200,15 @@ func _init() -> void:
insp_hint.text = "A = allow entry D = deny entry" insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12) insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint) ivbox.add_child(insp_hint)
add_child(inspect_panel) inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) --- # --- wand screening panel (popup center) ---
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new() wand_panel = PanelContainer.new()
wand_panel.visible = false wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new() var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox) wand_panel.add_child(wvbox)
wand_text = Label.new() wand_text = Label.new()
@@ -216,7 +222,7 @@ func _init() -> void:
wand_hint.text = "A = admit D = refuse entry" wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12) wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint) wvbox.add_child(wand_hint)
add_child(wand_panel) wand_center.add_child(wand_panel)
# --- debug overlay --- # --- debug overlay ---
debug_label = Label.new() debug_label = Label.new()
@@ -374,6 +380,7 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
continue continue
var card := TextureRect.new() var card := TextureRect.new()
card.texture = tex card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58) card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary() card.tooltip_text = artifact.summary()
@@ -382,7 +389,8 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
card_row.add_child(card) card_row.add_child(card)
added_card = true added_card = true
if added_card: if added_card:
inspect_panel.add_child(card_row) var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = [] var lines: Array[String] = []
for artifact in artifacts: for artifact in artifacts:
lines.append(" • " + artifact.summary()) lines.append(" • " + artifact.summary())
@@ -403,7 +411,7 @@ func _clear_card_row() -> void:
return return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row: if row:
inspect_panel.remove_child(row) row.get_parent().remove_child(row)
row.queue_free() row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void: func show_wand(controller: Object, result: Dictionary) -> void:
+5 -2
View File
@@ -34,9 +34,12 @@ func _init() -> void:
dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim) add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
panel = PanelContainer.new() panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER) center.add_child(panel)
add_child(panel)
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
panel.add_child(vbox) panel.add_child(vbox)
+6 -1
View File
@@ -12,9 +12,10 @@ var gm: Node = null
var state := 0 var state := 0
var state_frames := 0 var state_frames := 0
var targeted := false var targeted := false
var camera_staged := false
func _initialize() -> void: func _initialize() -> void:
root.size = Vector2i(1600, 900) root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn") var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate() var inst: Node = packed.instantiate()
root.add_child(inst) root.add_child(inst)
@@ -29,6 +30,10 @@ func _process(_delta: float) -> bool:
push_error("GameManager not found") push_error("GameManager not found")
quit(1) quit(1)
return true return true
if gm != null and not camera_staged:
gm.guard.global_position = Vector3(0, 1.6, 5.0)
gm.guard.head.rotation.x = deg_to_rad(-8.0)
camera_staged = true
if frames > MAX_FRAMES: if frames > MAX_FRAMES:
print("timeout reached") print("timeout reached")
quit(0) quit(0)
+276 -25
View File
@@ -3,17 +3,93 @@
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
""" """
import argparse
import base64
import io
import json
import math import math
import os import os
import random import random
import stat import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures") ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [ FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
@@ -21,12 +97,14 @@ FONT_CANDIDATES = [
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
] ]
def font(size: int): def font(size: int):
for path in FONT_CANDIDATES: for path in FONT_CANDIDATES:
if os.path.exists(path): if os.path.exists(path):
return ImageFont.truetype(path, size) return ImageFont.truetype(path, size)
return ImageFont.load_default() return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None: def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True) os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name) path = os.path.join(ASSETS, name)
@@ -34,6 +112,7 @@ def save(img: Image.Image, name: str) -> None:
img.save(path, optimize=True) img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0): def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed) rnd = random.Random(seed)
img = Image.new("L", (size, size), base) img = Image.new("L", (size, size), base)
@@ -44,6 +123,7 @@ def grain(size, base, spread, seed, blur=1.0):
px[x, y] = max(0, min(255, v)) px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur)) return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade(): def brick_facade():
size = 512 size = 512
mortar = (118, 110, 104) mortar = (118, 110, 104)
@@ -68,13 +148,14 @@ def brick_facade():
bx = rnd.randint(0, size - 1) bx = rnd.randint(0, size - 1)
by = 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)), 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[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18)))) max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]: for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar) d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png") save(img, "brick_facade.png")
def seamless_concrete(): def seamless_concrete():
size = 512 size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
@@ -83,7 +164,8 @@ def seamless_concrete():
for _ in range(90): for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4]) r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) 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 for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)] pts = [(x, y)]
@@ -94,6 +176,7 @@ def seamless_concrete():
d.line(pts, fill=(70, 70, 70, 170), width=1) d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png") save(img, "concrete.png")
def asphalt(): def asphalt():
size = 512 size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
@@ -101,9 +184,11 @@ def asphalt():
rnd = random.Random(31) rnd = random.Random(31)
for _ in range(600): for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size) 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),)) d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png") save(img, "asphalt.png")
def awning(): def awning():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45)) img = Image.new("RGB", (w, h), (140, 40, 45))
@@ -111,7 +196,8 @@ def awning():
stripe = 64 stripe = 64
for i in range(w // stripe): for i in range(w // stripe):
if i % 2 == 1: if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge # scalloped bottom edge
for x in range(0, w, 32): for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
@@ -125,6 +211,7 @@ def awning():
img = Image.alpha_composite(img.convert("RGBA"), shade) img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png") save(img, "awning.png")
def skyline(): def skyline():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
@@ -141,11 +228,13 @@ def skyline():
wx = x + rnd.randint(4, max(4, bw - 8)) wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4) wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5: if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46) x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png") save(img, "skyline.png")
def vignette(): def vignette():
size = 512 size = 512
img = Image.new("L", (size, size), 0) img = Image.new("L", (size, size), 0)
@@ -159,37 +248,51 @@ def vignette():
px[x, y] = int(255 * (t * t)) px[x, y] = int(255 * (t * t))
save(img, "vignette.png") save(img, "vignette.png")
def icon(name, draw_fn, size=64): def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
draw_fn(d, size) draw_fn(d, size)
save(img, name) save(img, name)
def icons(): def icons():
def flashlight_on(d, s): 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.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
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)) 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): 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.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) outline=(255, 255, 255, 255), width=5)
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(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): 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.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) 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): 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.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)) 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/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked) icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand) icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec) icon("icons/rec_dot.png", rec)
def app_icon(): def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage """Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO).""" (PNG) and the Windows installer (ICO)."""
def badge(d, s): def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255)) d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)], outline=(120, 130, 170, 255), width=max(2, s//64)) d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star # gold star
cx, cy, r = s*0.5, s*0.44, s*0.30 cx, cy, r = s*0.5, s*0.44, s*0.30
pts = [] pts = []
@@ -197,37 +300,60 @@ def app_icon():
ang = -90 + i * 36 ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42 rad = r if i % 2 == 0 else r * 0.42
import math import math
pts.append((cx + rad * math.cos(math.radians(ang)), cy + rad * math.sin(math.radians(ang)))) pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255)) d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024) icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256) icon("icon.ico", badge, size=256)
# multi-size ICO for Windows (from the 256 render) # ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
src = Image.open(os.path.join(ASSETS, "icon.png")) # PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico") ico_path = os.path.join(ASSETS, "icon.ico")
src.save(ico_path, format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)]) src_png = os.path.join(ASSETS, "icon.png")
print("wrote assets/textures/icon.ico (multi-size)") ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards(): def cards():
CW, CH = 256, 128 CW, CH = 256, 128
def base(card_name, bg, label, band=(0,0,0,0)):
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,)) img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255: if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band) d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26) f = font(26)
d.text((14, 10), label, font=f, fill=(255,255,255,235)) d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines # body text lines
f2 = font(15) f2 = font(15)
y = 52 y = 52
for _ in range(4): for _ in range(4):
w = 150 - (24 if _ == 3 else 0) w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24 y += 24
# photo square for id-like cards # photo square for id-like cards
if "photo" in card_name: if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png") save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) 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("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
@@ -235,7 +361,132 @@ def cards():
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 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)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def _generate_image(prompt: str, model: str, size: int,
transparent: bool = False) -> Image.Image:
"""Call the OpenRouter image API and return the decoded PIL image.
Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP
bytes returned in `data[].b64_json`. The requested size is clamped to at
least GEN_SIZE because some models (e.g. seedream) reject small outputs;
callers downscale the result to MAX_DIM afterwards. When `transparent` is
true, the API is asked for a transparent background (PNG alpha).
"""
size = max(GEN_SIZE, int(size))
body = {
"model": model,
"prompt": prompt,
"size": f"{size}x{size}",
"output_format": "png",
"n": 1,
}
if transparent:
body["background"] = "transparent"
req = urllib.request.Request(
OPENROUTER_IMAGES_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach OpenRouter: {exc.reason}")
data = payload.get("data") or []
if not data or not data[0].get("b64_json"):
sys.exit(f"error: OpenRouter returned no image data: {payload}")
raw = base64.b64decode(data[0]["b64_json"])
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception as exc: # noqa: BLE001 - surface any decode failure clearly
sys.exit(f"error: could not decode returned image: {exc}")
return img
def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
"""Downscale to fit within max_dim on the longest side, preserving aspect."""
w, h = img.size
longest = max(w, h)
if longest <= max_dim:
return img
scale = max_dim / longest
return img.resize((max(1, round(w * scale)), max(1, round(h * scale))),
Image.Resampling.LANCZOS)
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
transparent: bool = False) -> None:
"""Generate one texture via OpenRouter and save it under assets/textures/."""
img = _generate_image(prompt, model, size, transparent)
img = _fit(img)
if transparent:
img = img.convert("RGBA")
elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
save(img, name)
def _run_ai(args: argparse.Namespace) -> None:
if args.prompt:
if not args.name:
sys.exit("error: --name is required when --prompt is given")
generate_ai_textures(args.prompt, args.name, args.model, args.size)
return
for name, prompt, transparent in AI_TEXTURES:
print(f"generating {name} ...")
generate_ai_textures(prompt, name, args.model, args.size, transparent)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate Step Line textures (authored or via OpenRouter).")
parser.add_argument(
"--ai", action="store_true",
help="generate textures through the OpenRouter image API "
"(requires OPENROUTER_API_KEY)")
parser.add_argument(
"--prompt", default="",
help="single prompt for --ai (overrides the default batch)")
parser.add_argument(
"--name", default="",
help="output filename under assets/textures/ for a single --prompt")
parser.add_argument(
"--model", default=DEFAULT_IMAGE_MODEL,
help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})")
parser.add_argument(
"--size", type=int, default=GEN_SIZE,
help=f"square size requested from the API in px (min {GEN_SIZE}); "
f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})")
return parser
if __name__ == "__main__": if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade() brick_facade()
seamless_concrete() seamless_concrete()
asphalt() asphalt()