Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f076205af | ||
|
|
8bc84c1f36 | ||
|
|
418c30e334 | ||
|
|
39a80f39f9 | ||
|
|
5b994982bd | ||
|
|
0abd67fbaa |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 801 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 237 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
appimagetool.png
|
||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
usr/share/applications/appimagetool.desktop
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
usr/share/icons/hicolor/128x128/apps/appimagetool.png
|
||||||
@@ -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
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||