3 Commits
Author SHA1 Message Date
Matteo Benedetto 2c39d6235f Add gamepad support: stick look, trigger fire, pad menu confirm
- project.godot: joypad events on the ten existing actions (left stick move,
  A jump, right trigger/RB fire, X reload, B/LB grenade, Start pause, Back
  fullscreen), four new look_* actions on the right stick (deadzone 0.2) and
  ui_accept/ui_cancel redefined with the pad buttons A/B while keeping
  Enter/KP Enter/Space/Esc: the 4.7.1 engine maps no pad button on those.
- scripts/player.gd: gamepad_mode turns on by itself on the first pad input;
  _can_aim() replaces the "mouse captured" gate of shoot and grenade (the pad
  never captures the pointer, same trap already fixed for touch);
  _handle_gamepad_look() converts the right stick into equivalent relative
  mouse pixels (900 px/s at full deflection, m^1.8 response) and still goes
  through apply_look(); restart accepts A/X and the death text follows the
  device in use.
- scripts/start_screen.gd: pad hint shown only when a controller is connected,
  refreshed on Input.joy_connection_changed.
- tools/test_gamepad.gd: 33 checks on map structure, keyboard preservation,
  real event pipeline, gamepad_mode and analog look.
- docs/GAMEPAD.md: mapping, code map, verification, known limit (kiosk name
  entry still needs a keyboard: on-screen keyboard is the next step).

Verified: test_gamepad 33/33, test_fullscreen_shortcut 8/8, test_touch_controls
14/14 (--touch), test_grenade 24/24, test_boss 50/50, test_rebalance_branding
14/14, test_player_profile 30/30. test_start_screen keeps its 2 pre-existing
headless failures (identical on HEAD).
2026-10-24 17:42:26 +02:00
Matteo Benedetto b08ac8c6a1 Add Linux ARM64 preset, Raspberry Pi 5 docs and remote zip fetcher
- export_presets.cfg: new "Linux ARM64" preset (arch arm64, embedded PCK,
  ETC2/ASTC textures, S3TC/BPTC off since the Pi 5 V3D driver lacks it).
- tools/zipfetch.py: pull a single member out of the remote export-templates
  tpz with HTTP Range requests instead of downloading the whole 1.28 GB.
- docs/BUILD-RASPBERRY-PI.md: reproducible procedure, Pi 5 renderer
  expectations (GLES3 fallback, Forward+ downgraded to Mobile) and the
  pitfalls verified on the field.

Verified: arm64 export produced (74.094.536 B, PCK embedded at the end of the
official template); the x86_64 export from the same tree runs without missing
resources.
2026-10-24 16:08:32 +02:00
Matteo Benedetto 323a7cdbae Leaderboard attiva di default, senza configurazione
La classifica globale non dipende più da un argomento: sui build desktop il client
usa il servizio pubblico DEFAULT_API_BASE (https://enne2.net/api/zombie-fps).

- scripts/leaderboard.gd: resolve_base() statica con priorità --api=<url> (test)
  → --offline/--no-api (opt-out esplicito per le partite di prova) → origine della
  pagina sul Web → servizio pubblico. Rimosso l'avviso "Classifica disattivata".
- Le suite di test (--script) restano spente a meno di un --api= esplicito, così
  nessun test può scrivere nella classifica pubblica.
- tools/test_start_screen.gd: verifica default attivo, opt-out e override;
  tools/test_rebalance_branding.gd: suite di test senza invii.
- docs/HIGHSCORES.md: nuova regola della base URL e diagnostica aggiornata.

Verifiche: test_start_screen 18/18 PASS con display; test_rebalance_branding
14/14 PASS; test_leaderboard.sh invariato (12 PASS, 2 FAIL preesistenti su questa
macchina anche prima della modifica).
2026-10-24 15:57:42 +02:00
12 changed files with 697 additions and 32 deletions
+95
View File
@@ -0,0 +1,95 @@
# Build standalone per Raspberry Pi 5 (arm64)
Stato: **procedura verificata sulla generazione del binario** (export reale prodotto e
validato); la prova di esecuzione va fatta sul Pi 5, che non è questa macchina.
Artefatto prodotto il 2026-10-24 con Godot 4.7.1.stable (a13da4feb), commit `4af402f8`
(tag `v1.0.0-kiosk`).
## Esito
| Voce | Valore |
| --- | --- |
| File | `build/linux-arm64/work_death_balance.arm64` |
| Tipo | ELF 64-bit LSB, ARM aarch64, interpreter `/lib/ld-linux-aarch64.so.1` |
| Dimensione | 74.094.536 byte (≈70,7 MiB) = template 67.046.672 + PCK 7.047.864 |
| PCK | incorporato (`embed_pck=true`), magic `GDPC` a offset 67.046.672, pack_version 4 |
| Texture | `etc2_astc=true`, `s3tc_bptc=false` (v3d/V3D 7.1 non supporta S3TC/BPTC) |
| Addon nativo | non incluso: il preset esclude `addons/*`; nessun `.so` arm64 necessario |
## Perché serve poco lavoro
- I **template ufficiali Godot includono Linux arm64** (le architetture ufficiali sono
x86_32, x86_64, arm32, arm64). L'export è un **cross-export**: si esegue su x86_64,
non serve toolchain aarch64 né compilare il motore.
- La **GDExtension `goldsrc` non è nel gioco esportato**: `export_presets.cfg` ha
`exclude_filter="addons/*"` e l'unico riferimento nel codice è un commento in
`scripts/grenade_explosion.gd:3`. I modelli sono già `.scn` pre-estratti.
(Nel repository i binari dell'addon sono solo x86_64: irrilevante per l'export.)
## Procedura riproducibile
```bash
# 1. Godot 4.7.1 editor (x86_64) in ~/.local/opt/godot-4.7.1/
# https://github.com/godotengine/godot-builds/releases/tag/4.7.1-stable
# (solo il template che serve: templates/linux_release.arm64 dal tpz)
# 2. template installati in:
# ~/.local/share/godot/export_templates/4.7.1.stable/{linux_release.arm64,version.txt}
# version.txt deve contenere esattamente: 4.7.1.stable
# 3. import del progetto (genera .godot/), una volta sola
godot --headless --path . --import
# 4. export
godot --headless --path . --export-release "Linux ARM64" \
build/linux-arm64/work_death_balance.arm64
```
Il preset `Linux ARM64` è in `export_presets.cfg` (`[preset.2]`) ed è identico a
`Linux` tranne per: `binary_format/architecture="arm64"`,
`texture_format/etc2_astc=true`, `texture_format/s3tc_bptc=false`,
`export_path="build/linux-arm64/work_death_balance.arm64"`.
### Scaricare solo il template che serve (1,28 GB → 27 MB)
Il `tpz` dei template è uno zip: `tools/zipfetch.py` legge EOCD e central directory
via HTTP Range e scarica il solo membro richiesto. Comodo su connessioni lente.
## Sul Pi 5: aspettative e leve
GPU: Broadcom VideoCore VII (V3D 7.1). Mesa espone **OpenGL 3.1 desktop + OpenGL ES 3.1**
(v3d) e **Vulkan 1.2/1.3 conformante** (v3dv).
- Il progetto è `forward_plus`. Su Vulkan il Forward+ **viene declassato automaticamente
a Mobile** ("Platform supports less than 48 textures per stage … Defaulting to Mobile
renderer"): è il comportamento atteso, non un errore.
- Con `--rendering-driver opengl3` il driver desktop GL 3.3 non è disponibile su V3D e
Godot **ripiega su OpenGL ES 3.1**: per questo servono le texture ETC2 (già abilitate
nel preset arm64).
- Nessuna aspettativa da 1080p: il target realistico è 960x540 (o 640x360) con
`--max-fps 30`. Da misurare sul campo con `--print-fps` se compilato in debug.
Avvio consigliato (script fornito: `build/linux-arm64/run-arm64.sh`):
```bash
./run-arm64.sh # GL/GLES3, 960x540, 30 fps, classifica attiva
WDB_MODE=vulkan ./run-arm64.sh # percorso Vulkan (Mobile)
WDB_RES=640x360 ./run-arm64.sh # se non regge
WDB_API= ./run-arm64.sh # senza classifica
```
## Insidie verificate
1. **`--api=` solo dopo `--`**: `leaderboard.gd` legge `OS.get_cmdline_user_args()`.
Senza separatore il gioco stampa "Classifica disattivata".
2. **`curl -C -` senza `-L`** su GitHub scarica il corpo del 302 (0 byte) e sembra
"riuscito": il download resta fermo per sempre. Serve `-L`.
3. **Resume fragile**: la CDN può restituire 206 anche dopo uno stallo; con
`--speed-time 30 --speed-limit 20000` si riprende senza perdere i byte già scaricati.
4. **L'addon `goldsrc` non si carica su host con glibc più vecchia del build**
(`GLIBC_2.43 not found`): errore cosmetico in import/export, non incide sul PCK
perché `addons/*` è escluso.
5. **Verifica del PCK**: la controprova x86_64 esportata dallo stesso clone si avvia e
gira senza risorse mancanti; per l'arm64 l'unica verifica possibile qui è strutturale
(`file`, offset/size del PCK). La prova reale richiede il Pi.
+73
View File
@@ -0,0 +1,73 @@
# Gamepad / controller
Supporto ai controller implementato il 2026-10-24. Il gameplay non è stato
riscritto: tutte le azioni passano già dalla Input Map, quindi il lavoro è stato
mappare i binding, aggiungere il ramo analogico della visuale e aprire i gate che
prima pretendevano il mouse catturato.
## Mappatura (layout SDL/Xbox)
| Azione | Tastiera/mouse | Gamepad |
| --- | --- | --- |
| `move_forward` / `move_back` | W / S, frecce | stick sinistro Y (asse 1) |
| `move_left` / `move_right` | A / D, frecce | stick sinistro X (asse 0) |
| `jump` | Spazio | A (pulsante 0) |
| `shoot` | tasto sinistro del mouse | grilletto destro (asse 5) · RB (10) |
| `reload` | R | X (pulsante 2) |
| `grenade` | tasto destro del mouse | B (1) · LB (9) |
| `pause` | Esc | Start (6) |
| `toggle_fullscreen` | Alt+F | Back/Select (4) |
| `look_*` (nuove) | movimento del mouse | stick destro (assi 2 e 3) |
Tutti i binding joypad hanno `device = -1`, quindi funzionano con qualunque pad
collegato, non solo con il primo.
## Dove sta il codice
- `project.godot` → `[input]`: eventi `InputEventJoypadButton` / `InputEventJoypadMotion`
sulle azioni esistenti, quattro nuove azioni `look_left/right/up/down` (deadzone 0.2)
e la ridefinizione di `ui_accept`/`ui_cancel`.
- `scripts/player.gd`:
- `gamepad_mode`: si accende da solo al primo pulsante/stick usato (`_is_gamepad_activity`);
- `_can_aim()` sostituisce il gate `touch_mode or mouse_mode == CAPTURED` di sparo e
granata: senza, col pad non si sarebbe potuto né sparare né lanciare;
- `_handle_gamepad_look(dt)`: converte lo stick destro in "pixel relativi
equivalenti" (900 px/s a fondo corsa, curva `m^1.8`) e li passa ad `apply_look()`,
che resta l'unico punto con sensibilità e limiti di inclinazione;
- a partita finita ricomincia la partita anche con A/X, e il messaggio di morte
cambia testo in base al dispositivo in uso.
- `scripts/start_screen.gd`: riga di aiuto che compare **solo** se c'è un pad
collegato (aggiornata in hotplug via `Input.joy_connection_changed`).
## Perché `ui_accept` va ridefinito a mano
Nel motore 4.7.1 `ui_accept` è mappato su Invio, Invio del tastierino e Spazio, e
`ui_cancel` solo su Esc: **nessun pulsante di gamepad**. Con il pad si poteva
navigare fra i controlli (le `ui_up/down/left/right` hanno già dpad e stick) ma non
confermare. La ridefinizione in `project.godot` rimette anche i tasti di tastiera:
senza, Invio e Spazio avrebbero smesso di funzionare. Il test lo controlla.
## Verifica
```bash
godot --headless --path . --script res://tools/test_gamepad.gd
```
33 controlli: struttura della mappa, conservazione dei tasti su `ui_accept`/`ui_cancel`,
pipeline eventi reale (`Input.parse_input_event` con un pad su device 0), accensione di
`gamepad_mode`, rotazione della visuale con lo stick destro e limiti di inclinazione.
Con un pad vero conviene rifare la stessa prova a schermo reale
(`--rendering-method gl_compatibility`) perché il puntatore e il focus non sono
verificabili in headless. Suite esistenti riverificate: `test_fullscreen_shortcut`,
`test_touch_controls -- --touch`, `test_grenade`, `test_boss`, `test_rebalance_branding`,
`test_player_profile` tutte verdi. `test_start_screen` ha 2 FAIL preesistenti
(pulsante GIOCA in headless), identici su HEAD senza queste modifiche.
## Limite noto: il nome del giocatore
La regola arcade/kiosk chiede il nome a ogni avvio, e il campo è un `LineEdit` con
`virtual_keyboard_enabled`: la tastiera su schermo del motore però esiste solo su
Android/iOS/Web. Con **solo** il pad, su desktop non c'è modo di scrivere il nome.
Fase successiva: tastiera on-screen navigabile col dpad (griglia A-Z), oppure scelta
del nome da una lista di profili recenti.
+12 -8
View File
@@ -125,13 +125,17 @@ sudo nginx -t && sudo systemctl reload nginx # se -t fallisce: ripristinar
## 5. Client Godot
* **Base URL** (`scripts/leaderboard.gd`, `_resolve_base()`):
1. argomento `--api=http://host:porta` (test);
2. su Web: `window.location.origin + /api/zombie-fps`;
3. altrimenti **niente**: la classifica resta disattivata con
`DISABLED_REASON = "classifica non attiva in questa build"`.
Regola importante: una build desktop di prova **non deve** poter inviare punteggi
alla classifica pubblica (è già successo: ci sono finite righe `Test`).
* **Base URL** (`scripts/leaderboard.gd`, `resolve_base()`):
1. argomento `--api=http://host:porta` (test o servizio alternativo);
2. argomento `--offline` / `--no-api` → classifica **spenta** (partite di prova);
3. su Web: `window.location.origin + /api/zombie-fps`;
4. altrimenti il servizio pubblico `DEFAULT_API_BASE =
https://enne2.net/api/zombie-fps`.
La classifica è quindi **attiva di default**: non serve nesuna configurazione né
argomento per leggere e inviare punteggi. L'opt-out esiste solo per le partite di
prova, che non devono finire nella classifica pubblica (è già successo: ci sono
finite righe `Test`). Le suite di test girano con `--script` e restano spente a
meno di un `--api=` esplicito.
* Due nodi `HTTPRequest` separati (lettura e invio non si sovrappongono); timeout 12 s,
`body_size_limit` 256 KB / 64 KB.
* Retry su 429/5xx/errore di trasporto: fino a 3 tentativi, 6 s di pausa, **stesso
@@ -223,4 +227,4 @@ curl -s "https://enne2.net/api/zombie-fps/scores?limit=5"
| `classifica non disponibile (rete non disponibile)` nel menu | servizio spento o nginx non ricaricato: `docker ps`, `curl 127.0.0.1:8791/health`, `sudo nginx -t` |
| punteggio non inviato (`429`) | limite di frequenza per IP (20/ora, 5 s tra invii); i retry riusano lo stesso `submission_id`, quindi non raddoppiano |
| la pagina resta su una release vecchia | `index.html` è servito con `cache-control: no-cache`; forzare il ricaricamento della pagina |
| righe di prova nella classifica pubblica | una build desktop ha inviato senza `--api=`: verificare `leaderboard.disabled` (test `test_start_screen.gd`) |
| righe di prova nella classifica pubblica | la classifica è attiva di default: le partite di prova vanno lanciate con `-- --offline` (verificare `leaderboard.disabled`, test `test_start_screen.gd`) |
+28
View File
@@ -54,3 +54,31 @@ binary_format/architecture="x86_64"
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
ssh_remote_deploy/enabled=false
[preset.2]
name="Linux ARM64"
platform="Linux"
runnable=true
advanced_options=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter="addons/*,tools/*,docs/*,build/*,assets/**/*.mdl,assets/half_life_manifest.json"
export_path="build/linux-arm64/work_death_balance.arm64"
patches=PackedStringArray()
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.2.options]
custom_template/release=""
custom_template/debug=""
debug/export_console_wrapper=0
binary_format/embed_pck=true
binary_format/architecture="arm64"
texture_format/s3tc_bptc=false
texture_format/etc2_astc=true
ssh_remote_deploy/enabled=false
+51 -1
View File
@@ -44,54 +44,104 @@ move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
]
}
move_back={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
]
}
jump={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
shoot={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
reload={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null)
]
}
pause={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null)
]
}
toggle_fullscreen={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":4,"pressure":0.0,"pressed":false,"script":null)
]
}
grenade={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton, "button_index":2)]
"events": [Object(InputEventMouseButton, "button_index":2), Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null), Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":false,"script":null)]
}
look_left={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null)
]
}
look_right={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null)
]
}
look_up={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null)
]
}
look_down={
"deadzone": 0.2,
"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null)
]
}
ui_accept={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
ui_cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null)
]
}
[rendering]
+34 -10
View File
@@ -3,8 +3,12 @@ extends Node
##
## - Web: stessa origine della pagina (window.location.origin) + /api/zombie-fps, cosi'
## il browser non ha bisogno di CORS.
## - Desktop: nessuna classifica, a meno di --api=http://... (serve ai test). Cosi' una
## partita di prova non finisce MAI nella classifica pubblica.
## - Desktop: servizio pubblico `DEFAULT_API_BASE`, quindi la classifica e' ATTIVA DI
## DEFAULT: non serve nessuna configurazione ne' argomento per leggere e inviare.
## - Override espliciti: `--api=<url>` punta a un altro servizio (test), `--offline`
## (alias `--no-api`) spegne la classifica per le partite di prova.
## - Suite di test (avviate con `--script`): spente, a meno di un `--api=` esplicito,
## cosi' nessun test puo' scrivere nella classifica pubblica.
## - Due nodi HTTPRequest separati: Godot non ammette due richieste insieme sullo
## stesso nodo, e lettura e invio possono sovrapporsi (l'invio avviene alla morte).
## - Un errore di rete non deve mai rompere il gioco: si segnala e basta.
@@ -14,6 +18,8 @@ signal score_submitted(info: Dictionary)
signal submit_failed(reason: String)
const API_PATH := "/api/zombie-fps"
## Servizio pubblico: la classifica funziona senza alcuna configurazione.
const DEFAULT_API_BASE := "https://enne2.net/api/zombie-fps"
const TOP_LIMIT := 10
const RETRY_DELAY := 6.0
const RETRY_LIMIT := 3
@@ -22,7 +28,7 @@ const DISABLED_REASON := "classifica non attiva in questa build"
var player_id := ""
var base_url := ""
## Vero quando non c'e' nessun servizio da usare (build desktop senza --api=).
## Vero solo quando il servizio e' spento apposta (--offline) o in una suite di test.
var disabled := false
var _get_request: HTTPRequest
@@ -52,19 +58,37 @@ func _ready() -> void:
add_child(_retry_timer)
_retry_timer.timeout.connect(_send_pending)
## Ordine di priorita': --api=..., poi l'origine della pagina sul Web. Senza nessuno
## dei due non si usa NIENTE: una build desktop di prova non deve poter inviare
## punteggi alla classifica pubblica (e' successo: righe "Test" in produzione).
func _resolve_base() -> String:
for argument in OS.get_cmdline_user_args():
## Ordine di priorita': `--api=<url>` (test), `--offline`/`--no-api` (spegne), origine
## della pagina sul Web, altrimenti il servizio pubblico. La classifica e' quindi
## abilitata di default e non dipende da configurazione; `--offline` esiste solo per
## le partite di prova che non devono finire nella classifica pubblica.
## `script_run` e' il guard delle suite di test: non e' configurazione utente, la
## passa `_resolve_base()` quando il processo gira con `--script`.
static func resolve_base(user_args: PackedStringArray, script_run := false) -> String:
for argument in user_args:
if argument.begins_with("--api="):
return argument.substr(6).trim_suffix("/")
for argument in user_args:
if argument == "--offline" or argument == "--no-api":
return ""
if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"):
var origin := str(JavaScriptBridge.eval("window.location.origin", true))
if origin.begins_with("http"):
return origin.trim_suffix("/") + API_PATH
push_warning("Classifica disattivata: usa --api=http://host:porta per indicare il servizio.")
return ""
if script_run:
return "" # suite di test: mai verso il servizio pubblico
return DEFAULT_API_BASE
## Le suite di test girano come script (`--script ...`): in quel caso la classifica
## resta spenta, a meno che il test non abbia passato un suo `--api=`.
static func _is_script_run() -> bool:
for argument in OS.get_cmdline_args():
if argument == "--script" or argument == "-s":
return true
return false
func _resolve_base() -> String:
return resolve_base(OS.get_cmdline_user_args(), _is_script_run())
## Godot non ammette due richieste insieme sullo stesso nodo: una lettura chiesta
## mentre la precedente e' in volo viene rimandata a fine risposta invece di fallire
+57 -7
View File
@@ -34,6 +34,18 @@ var death_overlay: ColorRect
## True quando la partita e' guidata dai controlli on-screen (touch): il mouse
## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito.
var touch_mode := false
## True quando l'ultimo input utile arriva da un gamepad. Il pad non cattura il
## puntatore, quindi serve un flag esplicito: senza, sparo e granata resterebbero
## bloccati dal gate "mouse catturato" (la stessa trappola gia' corretta per il touch).
var gamepad_mode := false
## Punti-equivalenti di mouse al secondo con lo stick destro a fondo corsa: 900 px/s
## per gli 0.0025 rad/px di apply_look danno 2.25 rad/s (~129 gradi/s).
const GAMEPAD_LOOK_SPEED := 900.0
## Risposta dello stick: con 1.8 i piccoli spostamenti restano fini per mirare e il
## fondo corsa continua a girare veloce.
const GAMEPAD_LOOK_EXPONENT := 1.8
## Oltre questa ampiezza un movimento di stick conta come "sto usando il pad".
const GAMEPAD_ACTIVITY := 0.3
## Vero solo dopo l'inizio della partita: al menu il cursore deve restare libero,
## altrimenti la schermata iniziale non e' cliccabile.
var playing := false
@@ -95,7 +107,7 @@ var death_note: Label
## viene mai catturato.
func begin_play() -> void:
playing = true
if not touch_mode and DisplayServer.get_name() != "headless":
if not touch_mode and not gamepad_mode and DisplayServer.get_name() != "headless":
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o
@@ -113,12 +125,29 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
get_tree().call_deferred("reload_current_scene")
## Riconosce l'attivita' del pad (pulsante premuto o stick fuori dalla zona morta):
## cosi' il gioco passa da mouse a gamepad senza nessuna configurazione.
func _is_gamepad_activity(event: InputEvent) -> bool:
if event is InputEventJoypadButton:
return event.pressed
if event is InputEventJoypadMotion:
return absf(event.axis_value) > GAMEPAD_ACTIVITY
return false
## Il giocatore puo' mirare/sparare quando il puntatore e' catturato, oppure con touch
## o gamepad, che non catturano nulla.
func _can_aim() -> bool:
return touch_mode or gamepad_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
if _is_gamepad_activity(event):
gamepad_mode = true
if playing and OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if event is InputEventMouseMotion and not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
apply_look(event.relative)
if playing and event.is_action_pressed("pause") and not death_started:
# Con il pad il puntatore non viene catturato: il toggle del mouse non ha senso.
if playing and event.is_action_pressed("pause") and not death_started and not gamepad_mode:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
@@ -144,7 +173,7 @@ func start_reload() -> void:
func _handle_actions() -> void:
if Input.is_action_just_pressed("reload") and not reloading and ammo < 30:
start_reload()
if Input.is_action_just_pressed("grenade") and not death_started and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
if Input.is_action_just_pressed("grenade") and not death_started and _can_aim():
throw_grenade()
## Rotazione della visuale. Unico punto in cui vivono sensibilita' e limiti di
@@ -155,19 +184,38 @@ func apply_look(relative: Vector2) -> void:
rotate_y(-relative.x * 0.0025)
camera.rotation.x = clampf(camera.rotation.x - relative.y * 0.0025, -1.4, 1.4)
## Visuale analogica: il mouse arriva come InputEventMouseMotion relativo, lo stick
## destro come azioni. Qui lo stick diventa "pixel relativi equivalenti" e passa ad
## apply_look(), che resta l'unico posto con sensibilita' e limiti di inclinazione.
## La curva viene applicata sulla sola ampiezza, quindi la direzione resta esatta.
func _handle_gamepad_look(dt: float) -> void:
if not gamepad_mode or not playing or touch_mode:
return
if hp <= 0 or death_started:
return
# Deadzone esplicita 0.2, la stessa del movimento.
var look := Input.get_vector("look_left", "look_right", "look_up", "look_down", 0.2)
if look == Vector2.ZERO:
return
var magnitude := clampf(look.length(), 0.0, 1.0)
var shaped := look.normalized() * pow(magnitude, GAMEPAD_LOOK_EXPONENT)
apply_look(shaped * GAMEPAD_LOOK_SPEED * dt)
func _physics_process(dt: float) -> void:
cooldown -= dt
grenade_cooldown = maxf(0, grenade_cooldown - dt)
flash.light_energy = maxf(0, flash.light_energy - dt * 60)
gun.position.z = lerpf(gun.position.z, 0, dt * 15)
if hp <= 0:
if Input.is_action_just_pressed("reload"):
get_tree().reload_current_scene() # R o pulsante Ricarica a partita finita
# R (tastiera) oppure X/A del pad: stessi tempi, nessun menu da navigare.
if Input.is_action_just_pressed("reload") or Input.is_action_just_pressed("jump"):
get_tree().reload_current_scene() # R, X/A del pad o pulsante Ricarica a partita finita
return
_regenerate_health(dt)
if ammo <= 0 and not reloading:
start_reload()
_handle_actions()
_handle_gamepad_look(dt)
# Deadzone esplicita piccola (0.2): con la 0.5 delle azioni la corsa utile del
# joystick touch si dimezzerebbe, mentre per tastiera/joypad nulla cambia.
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back", 0.2)
@@ -184,7 +232,7 @@ func _physics_process(dt: float) -> void:
Audio.play(self, "player/pl_metal%d.wav" % randi_range(1, 4), false, -18)
if Input.is_action_just_pressed("shoot") and ammo <= 0 and not reloading:
Audio.play(self, "weapons/dryfire1.wav")
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and _can_aim():
shoot()
func shoot() -> void:
@@ -264,7 +312,9 @@ func _die() -> void:
tween.tween_property(camera, "rotation:x", 0.08, 0.9)
tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7)
var message := Label.new()
message.text = "SEI MORTO" if touch_mode else "SEI MORTO\nPremi R per ricominciare"
message.text = "SEI MORTO" if touch_mode else (
"SEI MORTO\nPremi A o X per ricominciare" if gamepad_mode
else "SEI MORTO\nPremi R per ricominciare")
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.add_theme_font_size_override("font_size", 36)
message.modulate.a = 0
+29
View File
@@ -16,6 +16,10 @@ var player: Node
var leaderboard: Node
var profile := {}
var name_field: LineEdit
var play_button: Button
## Riga di aiuto che compare solo quando c'e' un gamepad collegato: con mouse e
## tastiera la schermata resta identica a prima.
var pad_hint: Label
var rows_box: VBoxContainer
var status_label: Label
var me_label: Label
@@ -29,6 +33,9 @@ func _ready() -> void:
name_locked = Profile.is_name_locked(profile)
rows_shown = ROWS_TOUCH if (player != null and player.touch_mode) else ROWS_DESKTOP
_build_ui()
# Hotplug: la riga di aiuto del pad compare/scompare senza riavviare il gioco.
Input.joy_connection_changed.connect(_on_joy_connection_changed)
_refresh_pad_hint()
if leaderboard != null:
leaderboard.fetch()
@@ -75,6 +82,13 @@ func _build_ui() -> void:
play.custom_minimum_size = Vector2(0, 56)
play.pressed.connect(_start)
column.add_child(play)
play_button = play
# Aiuto per il pad: visibile solo se un controller e' collegato.
pad_hint = _label("", 13, DIM, HORIZONTAL_ALIGNMENT_CENTER)
pad_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
pad_hint.visible = false
column.add_child(pad_hint)
var panel := PanelContainer.new()
var style := StyleBoxFlat.new()
@@ -140,6 +154,21 @@ func _label(text: String, size: int, color: Color, align: int) -> Label:
label.add_theme_font_size_override("font_size", size)
return label
func _on_joy_connection_changed(_device: int, _connected: bool) -> void:
_refresh_pad_hint()
## Testo e visibilita' dell'aiuto gamepad. Il nome resta da inserire: con il solo pad
## serve una tastiera su schermo (fase successiva), qui si documentano i comandi.
func _refresh_pad_hint() -> void:
if pad_hint == null:
return
var pads := Input.get_connected_joypads()
pad_hint.visible = not pads.is_empty()
if not pads.is_empty():
pad_hint.text = "PAD — stick sx muovi · stick dx visuale · RT spara · X ricarica · B/LB granata · A salta · Start pausa · Back schermo intero"
if play_button != null and not play_button.has_focus() and name_field != null and not name_field.has_focus():
play_button.grab_focus()
func _set_status(text: String) -> void:
if status_label != null:
status_label.text = text
+196
View File
@@ -0,0 +1,196 @@
extends SceneTree
## Verifica il supporto gamepad:
## - ogni azione di gioco ha almeno un binding joypad, con i tasti/gli assi attesi;
## - gli assi di movimento sono sullo stick sinistro, la visuale sullo stick destro;
## - ui_accept/ui_cancel accettano A/B del pad SENZA perdere Invio/Spazio/Esc;
## - un pad reale (device != -1) attiva le azioni mappate con device -1;
## - l'attivita' del pad accende player.gamepad_mode, il puntatore non viene
## catturato e la visuale gira con lo stick destro passando da apply_look().
##
## Avviare con: --headless --path . --script res://tools/test_gamepad.gd
## Con un display reale: --rendering-method gl_compatibility
## azione -> eventi joypad attesi come stringhe leggibili (il formato del controllo)
const EXPECTED := {
"move_forward": "axis1-1.0",
"move_back": "axis1+1.0",
"move_left": "axis0-1.0",
"move_right": "axis0+1.0",
"jump": "btn0",
"reload": "btn2",
"pause": "btn6",
"toggle_fullscreen": "btn4",
"grenade": "btn1+btn9",
"shoot": "axis5+1.0+btn10",
"look_left": "axis2-1.0",
"look_right": "axis2+1.0",
"look_up": "axis3-1.0",
"look_down": "axis3+1.0",
}
var world: Node3D
var failures := 0
var checks := 0
func _initialize() -> void:
call_deferred("run_test")
func check(label: String, condition: bool, detail: String = "") -> void:
checks += 1
if condition:
print("PASS: " + label)
else:
failures += 1
print("FAIL: %s %s" % [label, detail])
## Rappresentazione compatta degli eventi joypad di un'azione, ordinata.
func _joypad_signature(action: String) -> String:
var parts: Array[String] = []
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton:
parts.append("btn%d" % event.button_index)
elif event is InputEventJoypadMotion:
parts.append("axis%d%+.1f" % [event.axis, event.axis_value])
parts.sort()
return "+".join(parts)
func _has_key(action: String, keycode: int) -> bool:
for event in InputMap.action_get_events(action):
if event is InputEventKey and (event.keycode == keycode or event.physical_keycode == keycode):
return true
return false
func _joypad_devices(action: String) -> Array[int]:
var devices: Array[int] = []
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
devices.append(event.device)
return devices
## Pressione/rilascio di un pulsante del pad, come un controller vero.
func _pad_button(index: int, pressed: bool, device := 0) -> void:
var event := InputEventJoypadButton.new()
event.device = device
event.button_index = index
event.pressed = pressed
Input.parse_input_event(event)
Input.flush_buffered_events()
## Movimento di un asse del pad (valore fra -1 e 1).
func _pad_axis(axis: int, value: float, device := 0) -> void:
var event := InputEventJoypadMotion.new()
event.device = device
event.axis = axis
event.axis_value = value
Input.parse_input_event(event)
Input.flush_buffered_events()
func _frames(count: int) -> void:
for i in count:
await process_frame
func _steps(count: int) -> void:
for i in count:
await physics_frame
func run_test() -> void:
# --- mappa dei comandi ---
for action: String in EXPECTED:
check("\"%s\" ha i binding joypad attesi" % action,
_joypad_signature(action) == EXPECTED[action],
"trovato \"%s\" atteso \"%s\"" % [_joypad_signature(action), EXPECTED[action]])
var all_any := true
var offenders: Array[String] = []
for action: String in EXPECTED:
for device in _joypad_devices(action):
if device != -1:
all_any = false
offenders.append("%s(device %d)" % [action, device])
check("i binding joypad accettano qualunque pad (device -1)", all_any, str(offenders))
# ui_*: il motore 4.7.1 non mappa alcun pulsante su ui_accept/ui_cancel, quindi
# vanno aggiunti a mano tenendo i tasti di tastiera gia' presenti.
check("ui_accept accetta il pulsante A del pad",
_joypad_signature("ui_accept") == "btn0", _joypad_signature("ui_accept"))
check("ui_accept conserva Invio", _has_key("ui_accept", KEY_ENTER))
check("ui_accept conserva Invio del tastierino", _has_key("ui_accept", KEY_KP_ENTER))
check("ui_accept conserva Spazio", _has_key("ui_accept", KEY_SPACE))
check("ui_cancel accetta il pulsante B del pad",
_joypad_signature("ui_cancel") == "btn1", _joypad_signature("ui_cancel"))
check("ui_cancel conserva Esc", _has_key("ui_cancel", KEY_ESCAPE))
# --- pipeline eventi: un pad qualsiasi (device 0) deve attivare le azioni ---
_pad_button(JOY_BUTTON_A, true)
await _frames(1)
check("A del pad attiva l'azione \"jump\"", Input.is_action_pressed("jump"))
check("A del pad attiva ui_accept", Input.is_action_pressed("ui_accept"))
_pad_button(JOY_BUTTON_A, false)
await _frames(1)
check("il rilascio di A spegne \"jump\"", not Input.is_action_pressed("jump"))
_pad_axis(JOY_AXIS_LEFT_Y, -1.0)
await _frames(1)
check("stick sinistro in alto attiva \"move_forward\"",
Input.is_action_pressed("move_forward"))
_pad_axis(JOY_AXIS_LEFT_Y, 0.0)
await _frames(1)
_pad_axis(JOY_AXIS_RIGHT_X, 1.0)
await _frames(1)
check("stick destro a destra attiva \"look_right\"", Input.is_action_pressed("look_right"))
_pad_axis(JOY_AXIS_RIGHT_X, 0.0)
await _frames(1)
# --- mondo di gioco: gamepad_mode e visuale analogica ---
world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
await _frames(2)
var player: Node = world.player
var screen = world.start_screen
var gioca: Button = null
if screen != null:
for button in screen.find_children("*", "Button", true, false):
if button.text == "GIOCA":
gioca = button
if screen != null and screen.get("name_field") != null:
screen.name_field.text = "PadTest"
if gioca != null:
gioca.pressed.emit()
await _frames(2)
check("la partita e' iniziata", player.playing == true, "playing=%s" % str(player.playing))
check("senza attivita' di pad gamepad_mode resta falso", player.gamepad_mode == false)
_pad_button(JOY_BUTTON_A, true)
_pad_button(JOY_BUTTON_A, false)
await _frames(1)
check("un pulsante del pad accende player.gamepad_mode", player.gamepad_mode == true)
var yaw_before: float = player.rotation.y
_pad_axis(JOY_AXIS_RIGHT_X, 1.0)
await _steps(12)
_pad_axis(JOY_AXIS_RIGHT_X, 0.0)
await _steps(2)
check("lo stick destro fa ruotare la visuale verso destra",
player.rotation.y < yaw_before - 0.01,
"prima=%.3f dopo=%.3f" % [yaw_before, player.rotation.y])
var pitch_before: float = player.camera.rotation.x
_pad_axis(JOY_AXIS_RIGHT_Y, 1.0)
await _steps(8)
_pad_axis(JOY_AXIS_RIGHT_Y, 0.0)
await _steps(2)
check("lo stick destro in basso abbassa l'inclinazione della camera",
player.camera.rotation.x < pitch_before,
"prima=%.3f dopo=%.3f" % [pitch_before, player.camera.rotation.x])
check("l'inclinazione resta dentro i limiti di apply_look",
player.camera.rotation.x >= -1.4 - 0.001, "pitch=%.3f" % player.camera.rotation.x)
# Il pad non deve catturare il puntatore (in headless non e' verificabile):
# quello che conta e' che il gate di sparo/granata non dipenda dal mouse.
check("con gamepad_mode il gate di mira e' aperto anche senza mouse catturato",
player._can_aim() == true and player.touch_mode == false,
"can_aim=%s touch=%s" % [str(player._can_aim()), str(player.touch_mode)])
print("%d controlli superati, %d falliti" % [checks - failures, failures])
quit(1 if failures > 0 else 0)
+4 -2
View File
@@ -1,5 +1,6 @@
extends SceneTree
## Exact damage regression + visible game identity, no public score submissions.
## Exact damage regression + visible game identity. La classifica e' attiva di default
## nelle build di gioco; qui, girando come --script, resta spenta.
const Player = preload("res://scripts/player.gd")
const Grenade = preload("res://scripts/grenade.gd")
const Boss = preload("res://scripts/alien_grunt.gd")
@@ -41,7 +42,8 @@ func run_test() -> void:
if label.text == "Work-Death Balance":
title_found = true
check(title_found, "menu mostra Work-Death Balance")
check(world.leaderboard.disabled, "build di prova non invia punteggi pubblici")
check(world.leaderboard.disabled and world.leaderboard.base_url == "",
"suite di test: nessun invio alla classifica pubblica")
var zombie = load("res://scripts/zombie.gd").new()
zombie.player = world.player
zombie.position = Vector3(0, 0.05, -3)
+16 -4
View File
@@ -8,6 +8,7 @@ extends SceneTree
## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility)
const Profile = preload("res://scripts/player_profile.gd")
const StartScreen = preload("res://scripts/start_screen.gd")
const Leaderboard = preload("res://scripts/leaderboard.gd")
var world: Node3D
var screen: CanvasLayer
@@ -101,10 +102,21 @@ func test_world_menu() -> void:
or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED),
"playing=%s mode=%s display=%s" % [str(world.player.playing), str(Input.mouse_mode),
DisplayServer.get_name()])
# Regressione: senza --api= questa e' una build desktop di prova e non deve poter
# inviare punteggi alla classifica pubblica (ci sono finite righe "Test").
check("senza --api= la build di prova non invia punteggi alla classifica pubblica",
world.leaderboard.disabled == true and world.leaderboard.base_url == "",
# La classifica e' attiva di default (nessuna configurazione). Il nodo qui e' spento
# perche' questa suite gira come --script: le regole si verificano su resolve_base().
check("senza argomenti la classifica e' attiva sul servizio pubblico",
Leaderboard.resolve_base(PackedStringArray(), false) == Leaderboard.DEFAULT_API_BASE
and Leaderboard.DEFAULT_API_BASE == "https://enne2.net/api/zombie-fps",
"base=%s" % str(Leaderboard.resolve_base(PackedStringArray(), false)))
check("--offline (e --no-api) spengono la classifica per le partite di prova",
Leaderboard.resolve_base(PackedStringArray(["--offline"]), false) == ""
and Leaderboard.resolve_base(PackedStringArray(["--no-api"]), false) == "")
check("--api= vince su --offline e normalizza la barra finale",
Leaderboard.resolve_base(PackedStringArray(["--offline", "--api=http://127.0.0.1:8799/"]), false)
== "http://127.0.0.1:8799")
check("in una suite di test la classifica resta spenta",
world.leaderboard.disabled == true and world.leaderboard.base_url == ""
and Leaderboard.resolve_base(PackedStringArray(), true) == "",
"base=%s disattivata=%s" % [str(world.leaderboard.base_url), str(world.leaderboard.disabled)])
world.queue_free()
await process_frame
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Estrae singoli file da uno ZIP remoto via HTTP Range (senza scaricare 1,28 GB).
Il tpz dei template Godot e' uno zip: serve solo templates/linux_release.arm64.
Legge EOCD + central directory con Range, poi scarica solo il membro richiesto.
"""
import binascii
import struct
import sys
import urllib.request
import zlib
URL = ("https://github.com/godotengine/godot-builds/releases/download/"
"4.7.1-stable/Godot_v4.7.1-stable_export_templates.tpz")
def get_range(url, start, end=None):
if end is None:
rng = f"bytes={start}-"
else:
rng = f"bytes={start}-{end}"
req = urllib.request.Request(url, headers={"Range": rng})
with urllib.request.urlopen(req, timeout=90) as r:
return r.read(), r.status
def main(wanted, dest_dir):
# 1. dimensione totale
req = urllib.request.Request(URL, method="HEAD")
with urllib.request.urlopen(req, timeout=60) as r:
total = int(r.headers["Content-Length"])
print(f"zip remoto: {total} byte")
# 2. EOCD negli ultimi 64 KiB
tail_len = min(65536, total)
tail, _ = get_range(URL, total - tail_len)
pos = tail.rfind(b"PK\x05\x06")
if pos < 0:
sys.exit("EOCD non trovato (zip64?)")
(_, _, _, _, n_entries, cd_size, cd_off, _) = struct.unpack(
"<IHHHHIIH", tail[pos:pos + 22])
print(f"voci: {n_entries}, central directory: {cd_off}+{cd_size}")
# 3. central directory
cd, _ = get_range(URL, cd_off, cd_off + cd_size - 1)
entries = {}
i = 0
while i + 46 <= len(cd) and cd[i:i + 4] == b"PK\x01\x02":
(method, csize, usize, name_len, extra_len, comment_len,
local_off) = (struct.unpack("<H", cd[i + 10:i + 12])[0],
struct.unpack("<I", cd[i + 20:i + 24])[0],
struct.unpack("<I", cd[i + 24:i + 28])[0],
struct.unpack("<H", cd[i + 28:i + 30])[0],
struct.unpack("<H", cd[i + 30:i + 32])[0],
struct.unpack("<H", cd[i + 32:i + 34])[0],
struct.unpack("<I", cd[i + 42:i + 46])[0])
name = cd[i + 46:i + 46 + name_len].decode("utf-8", "replace")
entries[name] = (method, csize, usize, local_off)
i += 46 + name_len + extra_len + comment_len
print(f"membri indicizzati: {len(entries)}")
if wanted not in entries:
candidati = [n for n in entries if "arm64" in n]
sys.exit(f"'{wanted}' non presente. Candidati arm64: {candidati}")
method, csize, usize, local_off = entries[wanted]
print(f"{wanted}: metodo={method} compresso={csize} originale={usize}")
# 4. header locale per sapere dove iniziano davvero i dati
lh, _ = get_range(URL, local_off, local_off + 29)
if lh[:4] != b"PK\x03\x04":
sys.exit("header locale inatteso")
name_len = struct.unpack("<H", lh[26:28])[0]
extra_len = struct.unpack("<H", lh[28:30])[0]
data_start = local_off + 30 + name_len + extra_len
# 5. dati compressi del solo membro
print(f"scarico {csize} byte da offset {data_start} ...")
data, status = get_range(URL, data_start, data_start + csize - 1)
print(f"ricevuti {len(data)} byte (http {status})")
if method == 8:
raw = zlib.decompress(data, -15)
elif method == 0:
raw = data
else:
sys.exit(f"metodo di compressione non gestito: {method}")
print(f"decompresso: {len(raw)} byte (atteso {usize})")
if len(raw) != usize:
sys.exit("dimensione non corrispondente")
out = f"{dest_dir}/{wanted.split('/')[-1]}"
with open(out, "wb") as fh:
fh.write(raw)
print(f"scritto {out}")
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else "templates/linux_release.arm64"
dest = sys.argv[2] if len(sys.argv) > 2 else "/tmp/godot-dl"
main(target, dest)