LANE6: rigged FPS viewmodel, weapon loadout + material matrix, 4 HUD styles

Hands/POV
- Cut a real first-person arms rig out of the GODVERSE modular character kit
  (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone
  mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime.
  Textures shrunk to 1k; 4.2 MB.
- ViewModel.gd instances that rig ONCE PER HAND and places each instance so its
  own hand bone lands on the grip — no IK. Grip orientation is measured off the
  rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so
  it survives re-tuning grip_rest_rot instead of needing new euler angles.
- Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon
  heft, idle breathe, landing dip, weapon lower/raise on swap.
- Sleeve material overridden (donor asset is a fantasy leather bracer); hand
  material forced non-metallic (its spec/gloss maps rendered skin as bronze).

Weapons
- Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the
  weapon-vs-material matrix from the founding chat.
- Smashable moves from binary hits_to_break to hp/toughness, giving three
  outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box
  cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet.
- The hit lands at Weapon.contact THROUGH the swing, not on the click — that
  delay is most of why the sledge feels different from the cutter.
- Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H.

HUD
- Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills
  in line items) and Dev, over one shared data feed. Scoring + combo multipliers.

Dev harness (not shipped)
- macOS screen-recording perms aren't available to the CLI, so the game records
  itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie,
  and dev/probe_*.gd print rig/scale/placement numbers.

Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's
already-unit side length, halving every box — which detached the bat's blade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Monster Robot Party 2026-07-31 16:34:00 +10:00
parent 17797b9ba0
commit 61757d24bc
53 changed files with 3269 additions and 288 deletions

3
.gitignore vendored
View File

@ -6,3 +6,6 @@
# Canonical copies: ~/Documents/Destroyulater/3D-STORE (M3) and
# johnking@ultra:~/Documents/3D-STORE (origin).
3D-STORE/
# Blender preview renders (build artefacts from tools/gen_*.py)
tools/previews/

View File

@ -1,11 +1,10 @@
# Destroyulator — Cascade Prototype
# Destroyulator — the game
Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. Built to prove two
things on the M3 *before* committing: the destruction feels good, and the frame rate holds.
Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
## Run it
**Editor (easiest):** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶.
**Editor:** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶.
**CLI:**
```sh
@ -16,34 +15,95 @@ things on the M3 *before* committing: the destruction feels good, and the frame
| Input | Does |
|---|---|
| **Left-click** | Swing the (nerf) hammer at whatever's under the cursor |
| **S** | Rain **500** rigid bodies — the **Day-0 stress gate**. Watch the FPS counter hold. |
| **R** | Reset the rack |
| **WASD / Space** | move / jump |
| **mouse** | look (Esc releases the cursor, click recaptures) |
| **LMB** | swing the equipped weapon |
| **16 · wheel · Q** | pick a weapon · cycle · swap to previous |
| **E · drag ←→ · LMB · G** | grab a record · slide the disc out · throw it · drop |
| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) |
| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) |
| **F** | report the disc in hand (Find-the-Misfiled-Disc) |
| **B · R** | rain 500 bodies (stress gate) · reset the store |
Top-left HUD shows **FPS · live body count · things smashed**.
## The weapon-vs-material matrix
## What it's proving — the cascade
This is the decision the game is built on. A swing deals `Weapon.power × Weapon.vs[material]`
against that material's `hp`, and there are three outcomes: it **breaks**, it **dents**
(knockback + a chip of damage), or it's **futile** — a dead clank, no score, no combo,
and a HUD nudge to switch tools.
The rack is nested the same way your store's DB is (`rack → shelf → crate → jacket → record`):
| | cardboard | paper | vinyl | glass | wood | steel |
|---|---|---|---|---|---|---|
| **Bare Hands** | ok | good | ok | ok | poor | useless |
| **Box Cutter** | **shreds** | **shreds** | good | poor | useless | useless |
| **Cricket Bat** | good | poor | **great** | **great** | ok | poor |
| **Crowbar** | ok | poor | ok | good | good | **great** |
| **Sledgehammer** | ok | poor | ok | **great** | **great** | **great** |
| **Fire Extinguisher** | ok | poor | ok | **great** | good | good |
- **Frame + shelves** start *frozen* (act static, hold everything up).
- Smash a **shelf** → it bursts into shards and vanishes → the **crates** on it fall →
the **cardboard jackets** spill → the **vinyl records** tumble out →
- **records are brittle** (`brittle_speed` in `Smashable.gd`) → they **crack a beat later
when they smack the floor**. A record that lands flat *survives* — there's your comedy/score beat.
Tuned in one place: `scripts/Weapon.gd` (`vs` tables) and `scripts/Smashable.gd`
(`PROFILES[kind].hp`). The office printer is the level boss — steel at `toughness_scale
= 5.0`, so it's a real fight with anything but the sledge.
No scripted "release" logic — gravity reads the hierarchy. That's the whole point.
Weapons also differ in **when** they connect, not just how hard: the hit lands at
`Weapon.contact` through the swing animation, so the sledgehammer commits late and heavy
while the box cutter is near-instant.
## Files
## The viewmodel
- `scripts/Smashable.gd` — one component for every breakable piece. The `PROFILES` table is
where **material identity** is authored (color · shard count · brittleness · bounce).
Swap `wood/cardboard/vinyl/glass/steel` behaviours here.
- `scripts/Main.gd` — builds the world + rack from primitives, hammer raycast, stress test, HUD.
`scripts/ViewModel.gd`. Real rigged hands (mixamorig, all 20 finger bones per hand)
holding real weapon meshes.
## Next step: real art (one function)
The arms rig is **instanced once per hand** and each instance is *placed* so its own hand
bone lands on the grip — no IK solver. That works because in first person you only ever
see forearm and hand. Placement solves two aims at once: the fist's bore lines up with
the weapon shaft, and the forearm runs back toward where that shoulder would be. Both
directions are **measured off the rig** at load (pinky-knuckle → index-knuckle for the
bore, elbow → hand for the forearm), so the grip survives re-tuning `grip_rest_rot`
instead of needing a fresh set of hand-authored euler angles.
Everything is grey primitives so it runs with zero import deps. To use your actual assets,
change `_piece()` in `Main.gd` to load a GLB instead of building a BoxMesh, e.g.
`load("res://assets/wooden-rack-type-long.glb").instantiate()` for the visual, keep the
convex collider. Your `3D-STORE/clean_glbs/*.glb` are ready to copy into `game/assets/`.
Motion is layered on one node: look-sway with spring-back, walk bob scaled by speed and
weapon heft, idle breathing, landing dip, and a keyframed swing arc per archetype
(`overhead` / `horizontal` / `jab` / `thrust`).
## Assets
| Path | Source |
|---|---|
| `assets/viewmodel/fps_arms.glb` | `tools/gen_fps_arms.py` — cut from the GODVERSE modular character kit |
| `assets/viewmodel/{bat,sledge,crowbar,cutter,extinguisher}.glb` | `tools/gen_viewmodel.py` — procedural, grip at origin, shaft +Y |
| `assets/store/*.glb` + `*.fractured.glb` | Lane 2 prop batch; the fractured sibling drives real chunk destruction |
| `assets/art/sleeves_*.jpg` | 3×3 sheets of generated cover art, windowed per record via uv1 offset |
Regenerate either set with:
```sh
/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/gen_viewmodel.py -- --render
```
## Dev harness
`dev/` is not shipped. Because macOS screen-recording permission isn't available to the
CLI, the game records **itself**:
```sh
Godot --path game --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 2250 dev/demo.tscn
```
`dev/DemoDriver.gd` drives the player through a scripted tour (every weapon, the material
matrix, the boss, all four HUDs) so a change can be eyeballed frame by frame without a
human at the keyboard. `dev/probe_*.gd` print rig/scale/placement numbers — renders are
too coarse to tell a 5 cm error from a 50 cm one.
## Smoke test before committing
```sh
/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300
```
Zero script errors required. After adding any `class_name` script, run
`Godot --path game --editor --quit` first — headless Godot won't rescan a stale class cache.
## What's still missing
The store has no **room**: props sit on a grey plane in a black void, and several topple
on spawn before you touch them. That's the biggest remaining gap — see the top-level README.

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cp466fj14b0au"
path="res://.godot/imported/bat.glb-fde6af7497f920088e328ef476093636.scn"
[deps]
source_file="res://assets/viewmodel/bat.glb"
dest_files=["res://.godot/imported/bat.glb-fde6af7497f920088e328ef476093636.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b2xbhblfod62o"
path="res://.godot/imported/crowbar.glb-5227fa992e89606ed9e64d9f75e4ee2d.scn"
[deps]
source_file="res://assets/viewmodel/crowbar.glb"
dest_files=["res://.godot/imported/crowbar.glb-5227fa992e89606ed9e64d9f75e4ee2d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dr3td1jjpjrxj"
path="res://.godot/imported/cutter.glb-a860977bff1edd51274eab6a23e1614f.scn"
[deps]
source_file="res://assets/viewmodel/cutter.glb"
dest_files=["res://.godot/imported/cutter.glb-a860977bff1edd51274eab6a23e1614f.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cld3xlfvdyrh4"
path="res://.godot/imported/extinguisher.glb-1ecb20235cfba5ef9a062a1682e6c825.scn"
[deps]
source_file="res://assets/viewmodel/extinguisher.glb"
dest_files=["res://.godot/imported/extinguisher.glb-1ecb20235cfba5ef9a062a1682e6c825.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cc5ak232haal1"
path="res://.godot/imported/fps_arms.glb-c4541bc0a837472906c8d1f33e70ab34.scn"
[deps]
source_file="res://assets/viewmodel/fps_arms.glb"
dest_files=["res://.godot/imported/fps_arms.glb-c4541bc0a837472906c8d1f33e70ab34.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4oalujrs6xf3"
path.s3tc="res://.godot/imported/fps_arms_Ch01_1001_Diffuse.png-3a8cbc3d5d7b6eecddebfc62ae09fe36.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "b174b03714c363b82148182744f2ecec"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png"
dest_files=["res://.godot/imported/fps_arms_Ch01_1001_Diffuse.png-3a8cbc3d5d7b6eecddebfc62ae09fe36.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4ns3mfi225js"
path.s3tc="res://.godot/imported/fps_arms_Ch01_1001_Glossiness.jpg-da38b6f72d363778d0bc807f523a2956.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "f4d230d4482b005c48aa98d94a9c9abd"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg"
dest_files=["res://.godot/imported/fps_arms_Ch01_1001_Glossiness.jpg-da38b6f72d363778d0bc807f523a2956.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cixl7kij11tnm"
path.s3tc="res://.godot/imported/fps_arms_Ch01_1001_Normal.jpg-48081de3acec5f8874bba39a1281e5c8.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "ac6ef533f6819bde1d35f13021a0a44e"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg"
dest_files=["res://.godot/imported/fps_arms_Ch01_1001_Normal.jpg-48081de3acec5f8874bba39a1281e5c8.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=1
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=1
roughness/src_normal="res://assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg"
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c1yr55brk4k1v"
path="res://.godot/imported/fps_arms_Ch01_1001_Specular.jpg-c1483d68a9c348d48cf19bc95be58371.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "411ca310ab143059e1f70d9c356d3a48"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg"
dest_files=["res://.godot/imported/fps_arms_Ch01_1001_Specular.jpg-c1483d68a9c348d48cf19bc95be58371.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://coo8brfh8uyfd"
path.s3tc="res://.godot/imported/fps_arms_Kachujin_diffuse.png-87f1edcbeea437685a597fe0cdc51522.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "c71ca51c64ab7a2d9e667398f1f29011"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Kachujin_diffuse.png"
dest_files=["res://.godot/imported/fps_arms_Kachujin_diffuse.png-87f1edcbeea437685a597fe0cdc51522.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://e40bwms0s1gg"
path.s3tc="res://.godot/imported/fps_arms_Kachujin_diffuse_body.jpg-c073cdee85fb6d70190607d7614810db.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "8dec0c04b9e08f28d2f0cb7339748f02"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg"
dest_files=["res://.godot/imported/fps_arms_Kachujin_diffuse_body.jpg-c073cdee85fb6d70190607d7614810db.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dhkhp7bxjgj5v"
path.s3tc="res://.godot/imported/fps_arms_Kachujin_normal.jpg-4d0da18d28874c322b3dd159a5765d10.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "c94b12525480aee058d5f35b3ea8a583"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Kachujin_normal.jpg"
dest_files=["res://.godot/imported/fps_arms_Kachujin_normal.jpg-4d0da18d28874c322b3dd159a5765d10.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=1
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=1
roughness/src_normal="res://assets/viewmodel/fps_arms_Kachujin_normal.jpg"
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bywaxo5e7i1qh"
path="res://.godot/imported/fps_arms_Kachujin_specular.jpg-c524a61c5cb12b35169a7cad07b523c0.ctex"
metadata={
"vram_texture": false
}
generator_parameters={
"md5": "35246783af3379f9cb73e7cdbecbe647"
}
[deps]
source_file="res://assets/viewmodel/fps_arms_Kachujin_specular.jpg"
dest_files=["res://.godot/imported/fps_arms_Kachujin_specular.jpg-c524a61c5cb12b35169a7cad07b523c0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bd6seig81j0ev"
path="res://.godot/imported/sledge.glb-5f5fcb550d7de7e5571b5d3f0a36eff5.scn"
[deps]
source_file="res://assets/viewmodel/sledge.glb"
dest_files=["res://.godot/imported/sledge.glb-5f5fcb550d7de7e5571b5d3f0a36eff5.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

156
game/dev/DemoDriver.gd Normal file
View File

@ -0,0 +1,156 @@
extends Node3D
## Dev-only capture harness. Loads main.tscn and drives the player through a scripted
## tour so a headless/CI run can record video of the actual game with Godot's
## --write-movie, without a human at the keyboard (and without screen-recording perms).
##
## /Applications/Godot.app/Contents/MacOS/Godot --path game \
## --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 900 \
## dev/demo.tscn
##
## Not shipped: excluded from export presets. Drives the player directly (position /
## yaw / pitch / _queue_attack) rather than faking InputEvents, so it stays immune to
## input-map changes.
const MAIN := "res://main.tscn"
var _main: Node3D
var _player: Player
var _t := 0.0
var _step := 0
var _step_t := 0.0
# Each step: {dur, move_to (Vector3 or null), look_at (Vector3 or null), act (String)}
# act: "" | "punch" | "kick" | "rain" | "reset"
var _script: Array = []
func _ready() -> void:
var packed := load(MAIN) as PackedScene
_main = packed.instantiate()
add_child(_main)
await get_tree().process_frame
_player = _find_player(_main)
if _player == null:
push_error("[demo] no Player found")
return
# the demo drives the camera; don't let the OS grab the pointer
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_build_script()
func _find_player(n: Node) -> Player:
if n is Player:
return n
for c in n.get_children():
var r := _find_player(c)
if r != null:
return r
return null
func _build_script() -> void:
# world layout (from Main._build_rack / _build_office, origin 0,0):
# racks (-0.95,-0.7) (0.95,-0.7)
# crates y=0.5 and y=1.2 rows, x -0.8..0.8
# desk+printer (2.7,-0.3) cabinet (-2.7,-0.3) cooler (3.3,1.6)
# CRT (-2.7,1.3) box (1.9,2.4) turntable (-1.7,-1.3)
_script = []
# ---- ACT 1: the loadout. Stand still and hold each weapon so the viewmodel,
# ---- the grip and one swing per archetype can all be inspected frame by frame.
for slot in range(6):
_script.append({"dur": 0.5, "move_to": Vector3(0, 0, 3.2),
"look_at": Vector3(0, 1.0, 0), "act": "weapon:%d" % slot})
_script.append({"dur": 1.0, "move_to": null, "look_at": null, "act": ""})
_script.append({"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"})
# ---- ACT 2: the matrix. Box cutter shreds the carton, then clanks off the
# ---- filing cabinet; the sledge then removes the cabinet in one go.
_script += [
{"dur": 0.6, "move_to": null, "look_at": null, "act": "weapon:1"},
{"dur": 1.2, "move_to": Vector3(1.9, 0, 3.4), "look_at": Vector3(1.9, 0.3, 2.4), "act": ""},
{"dur": 0.45, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.45, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
# cutter vs steel — expect the clank + the HUD nudge, and no damage
{"dur": 1.3, "move_to": Vector3(-2.7, 0, 1.1), "look_at": Vector3(-2.7, 0.6, -0.3), "act": ""},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.9, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 0.5, "move_to": null, "look_at": null, "act": "weapon:4"},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
]
# ---- ACT 3: the boss + the HUD styles
_script += [
{"dur": 1.4, "move_to": Vector3(2.7, 0, 1.3), "look_at": Vector3(2.7, 0.95, -0.3), "act": ""},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.6, "move_to": Vector3(0.6, 0, 5.2), "look_at": Vector3(0, 0.6, 0), "act": "hud"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"},
{"dur": 1.6, "move_to": null, "look_at": null, "act": "hud"},
]
func _process(dt: float) -> void:
if _player == null or _step >= _script.size():
return
_t += dt
var s: Dictionary = _script[_step]
var dur: float = s["dur"]
# fire the action when the step BEGINS, so a capture at step_start + n frames
# actually shows the swing rather than the second of stillness before it
if not s.get("_fired", false):
s["_fired"] = true
_do(String(s["act"]))
_step_t += dt
var mt = s.get("move_to")
if mt != null:
var to: Vector3 = mt
var k: float = clampf(_step_t / maxf(dur, 0.001), 0.0, 1.0)
k = k * k * (3.0 - 2.0 * k) # smoothstep so the dolly eases
var from: Vector3 = s.get("_from", _player.global_position)
if not s.has("_from"):
s["_from"] = _player.global_position
from = _player.global_position
_player.global_position = from.lerp(to, k)
var la = s.get("look_at")
if la != null:
_aim(la, clampf(_step_t / maxf(dur, 0.001), 0.0, 1.0))
if _step_t >= dur:
_step += 1
_step_t = 0.0
## Turn the body (yaw) + head (pitch) toward a world point, eased.
func _aim(target: Vector3, k: float) -> void:
var eye: Vector3 = _player.global_position + Vector3(0, _player.eye_height, 0)
var d: Vector3 = target - eye
if d.length() < 0.001:
return
var want_yaw := atan2(-d.x, -d.z)
var want_pitch := atan2(d.y, Vector2(d.x, d.z).length())
var e := clampf(k * 0.14, 0.0, 1.0) + 0.06
_player.rotation.y = lerp_angle(_player.rotation.y, want_yaw, e)
var head: Node3D = _player.get_node_or_null("Head")
if head != null:
head.rotation.x = lerp_angle(head.rotation.x, want_pitch, e)
func _do(act: String) -> void:
if act.begins_with("weapon:"):
_player.select(int(act.substr(7)))
return
match act:
"swing":
_player._queued = true # same path a left-click takes
"hud":
var hud = _main.get("_hud")
if hud != null:
hud.cycle()
"rain":
if _main.has_method("_rain"):
_main._rain(500)
"reset":
if _main.has_method("_reset"):
_main._reset()

View File

@ -0,0 +1 @@
uid://lveiga5ufogp

6
game/dev/demo.tscn Normal file
View File

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://dev/DemoDriver.gd" id="1_demo"]
[node name="Demo" type="Node3D"]
script = ExtResource("1_demo")

87
game/dev/probe_arms.gd Normal file
View File

@ -0,0 +1,87 @@
extends SceneTree
## Dev probe: dump how Godot imported fps_arms.glb (node tree, skeleton scale, bone
## names) so ViewModel.gd can be authored against the real numbers instead of guesses.
##
## Godot --headless --path game --script dev/probe_arms.gd
func _initialize() -> void:
_probe("res://assets/viewmodel/fps_arms.glb")
for w in ["bat", "sledge", "crowbar", "cutter", "extinguisher"]:
_probe("res://assets/viewmodel/%s.glb" % w, false)
quit()
func _probe(path: String, deep := true) -> void:
print("\n=========== ", path)
if not ResourceLoader.exists(path):
print(" MISSING")
return
var ps := load(path) as PackedScene
if ps == null:
print(" not a PackedScene")
return
var root := ps.instantiate()
_dump(root, 0, deep)
var skel := _find_skel(root)
if skel != null:
print(" -- skeleton: ", skel.name, " bones=", skel.get_bone_count())
print(" -- skeleton global xform: ", skel.transform)
# Godot sanitises glTF bone names on import, so print what actually landed
# rather than assuming the mixamorig: spelling survived.
for i in skel.get_bone_count():
var nm := skel.get_bone_name(i)
if nm.to_lower().contains("right") or nm.to_lower().contains("spine"):
print(" %-34s idx=%-3d parent=%-3d rest.origin=%s" % [
nm, i, skel.get_bone_parent(i), skel.get_bone_rest(i).origin])
# overall size
var aabb := _aabb(root)
print(" -- combined AABB pos=", aabb.position, " size=", aabb.size)
root.free()
func _dump(n: Node, d: int, deep: bool) -> void:
var pad := ""
for i in d:
pad += " "
var extra := ""
if n is MeshInstance3D:
var mi := n as MeshInstance3D
extra = " surfaces=%d skin=%s" % [
mi.mesh.get_surface_count() if mi.mesh else 0, str(mi.skin != null)]
if mi.mesh:
for s in mi.mesh.get_surface_count():
var m := mi.mesh.surface_get_material(s)
extra += " [mat%d=%s]" % [s, m.resource_name if m else "null"]
print(pad, n.name, " <", n.get_class(), ">", extra)
if not deep and d >= 1:
return
for c in n.get_children():
_dump(c, d + 1, deep)
func _find_skel(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _find_skel(c)
if r != null:
return r
return null
func _aabb(n: Node) -> AABB:
var acc := AABB()
var started := false
for mi in _meshes(n):
var a: AABB = (mi as MeshInstance3D).get_aabb()
a = (mi as Node3D).transform * a
if not started:
acc = a
started = true
else:
acc = acc.merge(a)
return acc
func _meshes(n: Node, acc: Array = []) -> Array:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_meshes(c, acc)
return acc

View File

@ -0,0 +1 @@
uid://bjtt6khwqooov

52
game/dev/probe_scale.gd Normal file
View File

@ -0,0 +1,52 @@
extends SceneTree
## What units does the imported arms rig actually live in? The Blender side reports bone
## lengths in centimetres but a metre-scale mesh AABB, so measure it in-engine with the
## scene actually in a tree (skinned AABBs are meaningless before that).
func _initialize() -> void:
var root := Node3D.new()
get_root().add_child(root)
var ps := load("res://assets/viewmodel/fps_arms.glb") as PackedScene
var inst: Node3D = ps.instantiate()
root.add_child(inst)
print("inst.transform = ", inst.transform)
for c in inst.get_children():
print(" child ", c.name, " <", c.get_class(), "> xform=", (c as Node3D).transform)
var skel := _skel(inst)
print("skel.transform = ", skel.transform)
print("skel global (rel inst) = ", inst.global_transform.affine_inverse() * skel.global_transform)
for n in ["mixamorig_RightHand", "mixamorig_RightForeArm", "mixamorig_RightHandIndex1"]:
var i := skel.find_bone(n)
print("%-30s global_rest.origin = %s" % [n, skel.get_bone_global_rest(i).origin])
var hr := skel.find_bone("mixamorig_RightHand")
var fa := skel.find_bone("mixamorig_RightForeArm")
var forearm_len: float = (skel.get_bone_global_rest(hr).origin
- skel.get_bone_global_rest(fa).origin).length()
print("forearm length (skeleton units) = ", forearm_len)
for mi in _meshes(inst):
var m := mi as MeshInstance3D
print("mesh %-16s local aabb=%s global aabb size=%s" % [
m.name, m.get_aabb().size, m.get_global_transform().basis * m.get_aabb().size])
quit()
func _skel(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _skel(c)
if r != null:
return r
return null
func _meshes(n: Node, acc: Array = []) -> Array:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_meshes(c, acc)
return acc

View File

@ -0,0 +1 @@
uid://de3cnb8nvrojh

85
game/dev/probe_vm.gd Normal file
View File

@ -0,0 +1,85 @@
extends SceneTree
## Where do the hand bones actually end up relative to the grip? Renders are too coarse
## to tell a 5 cm error from a 50 cm one, so print the numbers.
##
## Godot --headless --path game --script dev/probe_vm.gd
func _initialize() -> void:
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
get_root().add_child(main)
await process_frame
await process_frame
var p := _find(main, "Player") as Player
if p == null:
print("no player"); quit(); return
var vm := p.viewmodel
var cam := p.camera
for slot in [0, 2, 4]:
p.select(slot)
# select() defers the actual equip to the swap animation; force it for the probe
vm.equip(p.loadout()[slot])
await process_frame
var w: Weapon = p.loadout()[slot]
print("\n===== slot %d %s" % [slot, w.display_name])
var grip: Node3D = vm.get_node("Rig/Grip")
print(" grip (cam space) = ", cam.global_transform.affine_inverse() * grip.global_position)
for side in ["ArmR", "ArmL"]:
var arm := vm.get_node_or_null("Rig/" + side) as Node3D
if arm == null:
print(" %s: MISSING" % side); continue
var skel := _skel(arm)
var bone := "mixamorig_RightHand" if side == "ArmR" else "mixamorig_LeftHand"
var bi := skel.find_bone(bone)
var hand_world := skel.global_transform * skel.get_bone_global_pose(bi)
var in_cam := cam.global_transform.affine_inverse() * hand_world
print(" %s hand (cam space) = %s" % [side, in_cam.origin])
# where does the forearm run? (elbow -> hand direction, in camera space)
var fb := skel.find_bone("mixamorig_RightForeArm" if side == "ArmR" else "mixamorig_LeftForeArm")
var fore_world := skel.global_transform * skel.get_bone_global_pose(fb)
var fore_cam := cam.global_transform.affine_inverse() * fore_world
print(" elbow (cam space) = %s forearm dir = %s" % [
fore_cam.origin, (in_cam.origin - fore_cam.origin).normalized()])
if vm.get_node_or_null("Rig/Grip").get_child_count() > 0:
var wn := grip.get_child(0) as Node3D
var ab := _aabb(wn)
print(" weapon aabb (local) pos=%s size=%s" % [ab.position, ab.size])
quit()
func _find(n: Node, cls: String) -> Node:
if n.get_class() == cls or (cls == "Player" and n is Player):
return n
for c in n.get_children():
var r := _find(c, cls)
if r != null:
return r
return null
func _skel(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _skel(c)
if r != null:
return r
return null
func _aabb(n: Node) -> AABB:
var acc := AABB()
var started := false
for m in _meshes(n):
var a: AABB = (m as MeshInstance3D).get_aabb()
if not started:
acc = a; started = true
else:
acc = acc.merge(a)
return acc
func _meshes(n: Node, acc: Array = []) -> Array:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_meshes(c, acc)
return acc

1
game/dev/probe_vm.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://cd2it4cwus0xw

347
game/scripts/Hud.gd Normal file
View File

@ -0,0 +1,347 @@
extends CanvasLayer
class_name Hud
## Four selectable HUD styles over one shared data feed. Cycle with H.
##
## ARCADE ...... big score, combo meter, weapon card. The default: this is a
## score-attack game about a rising combo, so the combo is the loudest
## thing on screen.
## MINIMAL ..... crosshair-adjacent only. For screenshots and for people who want the
## room, not the numbers. Combo appears only while it's alive.
## WORK ORDER .. the joke, played straight: a corporate destruction docket that fills
## in line items as you wreck the place. Same data, funnier framing.
## DEV ......... FPS / body count / weapon internals. What the prototype used to show.
##
## Main pushes a plain Dictionary in via `feed()` once a frame; every style reads the
## same keys, so adding a style never means touching the game code.
##
## Godot 4.7 GDScript 2.0.
enum Style { ARCADE, MINIMAL, WORKORDER, DEV }
const STYLE_NAMES := ["ARCADE", "MINIMAL", "WORK ORDER", "DEV"]
const PINK := Color(1.0, 0.36, 0.62)
const CREAM := Color(0.94, 0.92, 0.86)
const INK := Color(0.11, 0.10, 0.12)
var style: int = Style.ARCADE
var _data: Dictionary = {}
var _roots: Array[Control] = []
# --- arcade widgets
var _a_score: Label
var _a_combo: Label
var _a_combo_bar: ColorRect
var _a_combo_bg: ColorRect
var _a_weapon: Label
var _a_weapon_sub: Label
var _a_mode: Label
var _a_toast: Label
var _toast_left := 0.0
# --- minimal
var _m_combo: Label
var _m_weapon: Label
# --- work order
var _w_lines: Label
var _w_head: Label
# --- dev
var _d_text: Label
func _ready() -> void:
layer = 10
_build_arcade()
_build_minimal()
_build_workorder()
_build_dev()
_apply()
func cycle() -> void:
style = (style + 1) % STYLE_NAMES.size()
_apply()
toast("HUD: %s" % STYLE_NAMES[style])
func _apply() -> void:
for i in _roots.size():
_roots[i].visible = (i == style)
## Main calls this every frame. Keys: score, combo, smashed, bodies, fps, weapon,
## weapon_blurb, slot, mode_line, time_left, tally (Dictionary kind->count).
func feed(d: Dictionary) -> void:
_data = d
## A transient centre-screen message (weapon pickup, HUD change, futile-hit hint).
func toast(msg: String) -> void:
if _a_toast != null:
_a_toast.text = msg
_toast_left = 1.7
# ---------------------------------------------------------------- helpers
func _label(parent: Control, size: int, col: Color, shadow := true) -> Label:
var l := Label.new()
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
if shadow:
l.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.75))
l.add_theme_constant_override("shadow_offset_x", 2)
l.add_theme_constant_override("shadow_offset_y", 2)
parent.add_child(l)
return l
func _panel(parent: Control, col: Color) -> ColorRect:
var r := ColorRect.new()
r.color = col
parent.add_child(r)
return r
func _root() -> Control:
var c := Control.new()
c.set_anchors_preset(Control.PRESET_FULL_RECT)
c.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(c)
_roots.append(c)
return c
# ---------------------------------------------------------------- ARCADE
func _build_arcade() -> void:
var r := _root()
_a_score = _label(r, 54, Color.WHITE)
_a_score.anchor_left = 0.5
_a_score.anchor_right = 0.5
_a_score.offset_left = -260
_a_score.offset_right = 260
_a_score.offset_top = 14
_a_score.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_a_combo = _label(r, 26, PINK)
_a_combo.anchor_left = 0.5
_a_combo.anchor_right = 0.5
_a_combo.offset_left = -260
_a_combo.offset_right = 260
_a_combo.offset_top = 74
_a_combo.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_a_combo_bg = _panel(r, Color(1, 1, 1, 0.13))
_a_combo_bg.anchor_left = 0.5
_a_combo_bg.anchor_right = 0.5
_a_combo_bg.offset_left = -130
_a_combo_bg.offset_right = 130
_a_combo_bg.offset_top = 108
_a_combo_bg.offset_bottom = 116
_a_combo_bar = _panel(r, PINK)
_a_combo_bar.anchor_left = 0.5
_a_combo_bar.anchor_right = 0.5
_a_combo_bar.offset_left = -130
_a_combo_bar.offset_right = -130
_a_combo_bar.offset_top = 108
_a_combo_bar.offset_bottom = 116
# weapon card, bottom right
var card := _panel(r, Color(0, 0, 0, 0.42))
card.anchor_left = 1.0
card.anchor_right = 1.0
card.anchor_top = 1.0
card.anchor_bottom = 1.0
card.offset_left = -330
card.offset_right = -18
card.offset_top = -84
card.offset_bottom = -18
_a_weapon = _label(r, 25, Color.WHITE)
_a_weapon.anchor_left = 1.0
_a_weapon.anchor_right = 1.0
_a_weapon.anchor_top = 1.0
_a_weapon.anchor_bottom = 1.0
_a_weapon.offset_left = -318
_a_weapon.offset_right = -26
_a_weapon.offset_top = -78
_a_weapon.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_a_weapon_sub = _label(r, 14, Color(1, 1, 1, 0.62))
_a_weapon_sub.anchor_left = 1.0
_a_weapon_sub.anchor_right = 1.0
_a_weapon_sub.anchor_top = 1.0
_a_weapon_sub.anchor_bottom = 1.0
_a_weapon_sub.offset_left = -318
_a_weapon_sub.offset_right = -26
_a_weapon_sub.offset_top = -46
_a_weapon_sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_a_weapon_sub.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_a_mode = _label(r, 16, Color(1, 1, 1, 0.78))
_a_mode.anchor_top = 1.0
_a_mode.anchor_bottom = 1.0
_a_mode.offset_left = 20
_a_mode.offset_top = -44
_a_toast = _label(r, 22, Color.WHITE)
_a_toast.anchor_left = 0.5
_a_toast.anchor_right = 0.5
_a_toast.anchor_top = 0.5
_a_toast.anchor_bottom = 0.5
_a_toast.offset_left = -300
_a_toast.offset_right = 300
_a_toast.offset_top = 84
_a_toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# ---------------------------------------------------------------- MINIMAL
func _build_minimal() -> void:
var r := _root()
_m_combo = _label(r, 22, PINK)
_m_combo.anchor_left = 0.5
_m_combo.anchor_right = 0.5
_m_combo.anchor_top = 0.5
_m_combo.anchor_bottom = 0.5
_m_combo.offset_left = -160
_m_combo.offset_right = 160
_m_combo.offset_top = 46
_m_combo.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_m_weapon = _label(r, 15, Color(1, 1, 1, 0.5))
_m_weapon.anchor_left = 1.0
_m_weapon.anchor_right = 1.0
_m_weapon.anchor_top = 1.0
_m_weapon.anchor_bottom = 1.0
_m_weapon.offset_left = -260
_m_weapon.offset_right = -20
_m_weapon.offset_top = -36
_m_weapon.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
# ---------------------------------------------------------------- WORK ORDER
func _build_workorder() -> void:
var r := _root()
# tall enough for the header + 7 material rows + the totals block; the docket runs
# off the bottom of a shorter panel once you've broken one of everything
var paper := _panel(r, CREAM)
paper.offset_left = 18
paper.offset_top = 16
paper.offset_right = 372
paper.offset_bottom = 330
var strip := _panel(r, INK)
strip.offset_left = 18
strip.offset_top = 16
strip.offset_right = 372
strip.offset_bottom = 52
_w_head = _label(r, 15, CREAM, false)
_w_head.offset_left = 30
_w_head.offset_top = 24
_w_head.offset_right = 366
_w_lines = _label(r, 14, INK, false)
_w_lines.offset_left = 30
_w_lines.offset_top = 62
_w_lines.offset_right = 362
_w_lines.offset_bottom = 322
# ---------------------------------------------------------------- DEV
func _build_dev() -> void:
var r := _root()
_d_text = _label(r, 16, PINK)
_d_text.offset_left = 16
_d_text.offset_top = 12
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
if _data.is_empty():
return
_toast_left = maxf(0.0, _toast_left - dt)
match style:
Style.ARCADE:
_tick_arcade()
Style.MINIMAL:
_tick_minimal()
Style.WORKORDER:
_tick_workorder()
Style.DEV:
_tick_dev()
func _g(k: String, dflt = 0):
return _data.get(k, dflt)
func _tick_arcade() -> void:
_a_score.text = "%s" % _comma(int(_g("score")))
var combo := int(_g("combo"))
if combo > 1:
_a_combo.text = "COMBO x%d" % combo
_a_combo.modulate.a = 1.0
else:
_a_combo.text = ""
var decay := float(_g("combo_decay", 0.0)) # 1 = fresh, 0 = about to drop
_a_combo_bg.visible = combo > 1
_a_combo_bar.visible = combo > 1
_a_combo_bar.offset_right = -130.0 + 260.0 * clampf(decay, 0.0, 1.0)
_a_weapon.text = "%d · %s" % [int(_g("slot")) + 1, str(_g("weapon", ""))]
_a_weapon_sub.text = str(_g("weapon_blurb", ""))
_a_mode.text = str(_g("mode_line", ""))
_a_toast.modulate.a = clampf(_toast_left / 0.5, 0.0, 1.0)
func _tick_minimal() -> void:
var combo := int(_g("combo"))
_m_combo.text = ("x%d" % combo) if combo > 1 else ""
_m_weapon.text = str(_g("weapon", ""))
func _tick_workorder() -> void:
_w_head.text = "MONSTER ROBOT PARTY · WORK ORDER #%04d" % int(_g("order_no", 4471))
var tally: Dictionary = _g("tally", {})
var lines := PackedStringArray()
lines.append("SITE: LEVEL 01 — THE RECORD STORE")
lines.append("TOOL: %s" % str(_g("weapon", "")))
lines.append("")
lines.append("ITEM QTY")
lines.append("---------------------------- --")
var order := ["wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper"]
var any := false
for k in order:
var n := int(tally.get(k, 0))
if n <= 0:
continue
any = true
lines.append("%-27s %3d" % [_item_name(k), n])
if not any:
lines.append("(nothing logged yet)")
lines.append("")
lines.append("TOTAL UNITS DESTROYED %6d" % int(_g("smashed")))
lines.append("ASSESSED VALUE %6s" % _comma(int(_g("score"))))
var combo := int(_g("combo"))
if combo > 1:
lines.append("EFFICIENCY BONUS x%d" % combo)
_w_lines.text = "\n".join(lines)
func _item_name(kind: String) -> String:
match kind:
"wood": return "Shelving, timber"
"cardboard": return "Cartons, corrugated"
"vinyl": return "Stock, 12in vinyl"
"glass": return "Glazing / CRT"
"steel": return "Fixtures, steel"
"plastic": return "Fittings, moulded"
"paper": return "Paperwork"
return kind
func _tick_dev() -> void:
_d_text.text = "FPS %d bodies %d smashed %d score %d combo x%d\n%s\n%s\n%s" % [
int(_g("fps")), int(_g("bodies")), int(_g("smashed")),
int(_g("score")), int(_g("combo")),
"weapon: %s (slot %d) %s" % [str(_g("weapon", "-")), int(_g("slot")) + 1,
str(_g("weapon_stats", ""))],
str(_g("mode_line", "")),
str(_g("keys", ""))]
func _comma(n: int) -> String:
var s := str(absi(n))
var out := ""
var c := 0
for i in range(s.length() - 1, -1, -1):
out = s[i] + out
c += 1
if c % 3 == 0 and i > 0:
out = "," + out
return ("-" if n < 0 else "") + out

1
game/scripts/Hud.gd.uid Normal file
View File

@ -0,0 +1 @@
uid://bovbusa0dmqe8

View File

@ -27,9 +27,32 @@ var _sounds: Dictionary = {} # kind -> AudioStreamWAV
func setup(cam: Camera3D) -> void:
_cam = cam
process_mode = Node.PROCESS_MODE_ALWAYS # keep releasing hitstop even during a freeze
for k in ["wood", "cardboard", "vinyl", "glass", "steel"]:
for k in ["wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper", "clank"]:
_sounds[k] = _make_wav(_synth(k), 22050)
## A hit that couldn't hurt the material — box cutter on a filing cabinet. Deliberately
## unsatisfying: a dead clank, a token wobble, no particles, and NO combo credit. It has
## to read as "wrong tool" without a tutorial popup.
func resist(_kind: String, pos: Vector3) -> void:
_play_sound("clank", pos)
add_trauma(0.09)
## A hit that hurt but didn't kill. The material's own voice, a small spray of chips,
## and a shorter hitstop than a kill so heavy props feel like work, not like a stall.
func dent(kind: String, pos: Vector3, _frac: float) -> void:
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
_burst(color, pos, 5)
_play_sound(kind, pos)
add_trauma(0.16)
_hitstop(0.025)
## 1.0 just after a smash, falling to 0.0 as the combo window closes. Drives the HUD bar.
func combo_decay() -> float:
if _combo <= 1:
return 0.0
var left := float(combo_window_ms) - float(Time.get_ticks_msec() - _last_smash_ms)
return clampf(left / float(combo_window_ms), 0.0, 1.0)
## Called once per shatter (Main forwards Smashable.smashed here).
func impact(kind: String, pos: Vector3, shards: int) -> void:
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
@ -134,6 +157,9 @@ func _synth(kind: String) -> PackedFloat32Array:
"steel": dur = 0.55
"vinyl": dur = 0.22
"cardboard": dur = 0.18
"plastic": dur = 0.20
"paper": dur = 0.16
"clank": dur = 0.14
var n := int(rate * dur)
var out := PackedFloat32Array()
out.resize(n)
@ -155,6 +181,17 @@ func _synth(kind: String) -> PackedFloat32Array:
"steel":
env = exp(-t * 5.0)
s = sin(TAU * 190.0 * t) * 0.5 * env + sin(TAU * 285.0 * t) * 0.25 * env + sin(TAU * 470.0 * t) * 0.12 * env + (randf() * 2.0 - 1.0) * 0.2 * exp(-t * 40.0)
"plastic":
env = exp(-t * 24.0)
s = sin(TAU * 780.0 * t) * 0.35 * env + (randf() * 2.0 - 1.0) * 0.4 * env
"paper":
env = exp(-t * 30.0)
s = (randf() * 2.0 - 1.0) * 0.32 * env * (0.5 + 0.5 * sin(TAU * 40.0 * t))
"clank":
# short, dull, no sustain — the sound of a tool bouncing off, and of
# a decision to swap weapon.
env = exp(-t * 46.0)
s = sin(TAU * 240.0 * t) * 0.34 * env + (randf() * 2.0 - 1.0) * 0.26 * exp(-t * 90.0)
_: # wood (and default)
env = exp(-t * 20.0)
s = (randf() * 2.0 - 1.0) * 0.7 * env + sin(TAU * 140.0 * t) * 0.4 * env

View File

@ -14,8 +14,17 @@ extends Node3D
@onready var _cam: Camera3D
var _world: Node3D # everything smashable/debris lives here so reset is one line
var _hud: Label
var _hud: Hud
var _smash_count := 0
var _score := 0
var _tally := {} # material -> how many of it you've destroyed (work-order HUD)
## Score per material. Steel is worth the most because it's the hardest to break, which
## is what makes the sledgehammer worth its cooldown.
const POINTS := {
"paper": 5, "cardboard": 10, "plastic": 20, "vinyl": 25,
"wood": 30, "glass": 40, "steel": 60,
}
var _juice: Juice
var _player: Player # kept so GameMode can reach player.grab
var _records: Array[Record] = [] # the live records, handed to GameMode each round
@ -145,7 +154,7 @@ func _build_office(origin: Vector3) -> void:
var printer := _glb_piece(PRINTER_GLB, "steel", o + Vector2(2.7, -0.3), false, desk["top"])
var boss := printer["piece"] as Smashable
boss.is_boss = true
boss.hits_to_break = 5 # the boss soaks a beating before it bursts
boss.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge
boss.mass = 8.0 # heavy, so hits rock it but don't shove it off the desk
(desk["piece"] as Smashable).supports.append(printer["piece"])
@ -170,10 +179,10 @@ func _place_record(xz: Vector2, sit_on: float) -> Record:
_skin_record(rec.sleeve_visual) # random cover art on the jacket faces
var ab := _body_local_aabb(rec, rec.sleeve_visual) if rec.sleeve_visual != null else AABB()
rec.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y)
rec.smashed.connect(_on_smashed) # the empty sleeve breaking counts + juices
_wire(rec) # the empty sleeve breaking counts + juices
var disc := rec.disc_body()
if disc != null:
disc.smashed.connect(_on_smashed) # the cracked disc counts + juices too
_wire(disc) # the cracked disc counts + juices too
_records.append(rec)
return rec
@ -204,7 +213,7 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on :=
col.position = ab.get_center()
s.add_child(col)
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on
s.smashed.connect(_on_smashed)
_wire(s)
return {"piece": s, "top": sit_on + ab.size.y}
## Give a record's sleeve faces a random cover from the generated 3x3 sheets.
@ -304,31 +313,59 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder :=
s.add_child(col)
_world.add_child(s) # _ready() runs here (kind + frozen already set)
s.global_position = pos
s.smashed.connect(_on_smashed)
_wire(s)
return s
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_smash_count += 1
_tally[kind] = int(_tally.get(kind, 0)) + 1
if _juice != null:
_juice.impact(kind, at, shards)
# score AFTER impact() so this break's own combo step is counted
_score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1)
else:
_score += int(POINTS.get(kind, 15))
if _game_mode != null:
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
## A swing that couldn't hurt what it hit. No score, no combo — just the clank, plus a
## one-time nudge toward the right tool.
func _on_resisted(kind: String, at: Vector3) -> void:
if _juice != null:
_juice.resist(kind, at)
if _hud != null:
_hud.toast("%s shrugs it off — try a heavier tool" % kind.to_upper())
func _on_damaged(kind: String, at: Vector3, frac: float) -> void:
if _juice != null:
_juice.dent(kind, at, frac)
## Every Smashable routes its three outcomes here.
func _wire(s: Smashable) -> void:
s.smashed.connect(_on_smashed)
s.resisted.connect(_on_resisted)
s.damaged.connect(_on_damaged)
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
# Melee (L-click punch / R-click kick), grab (E/G/drag), and Esc live in Player.gd.
# Here: debug keys + the game-mode keys. Rain is on B (S walks backward now).
# Swings, weapon slots (1-6 / wheel / Q), grab (E/G/drag) and Esc live in Player.gd.
# Number keys are the LOADOUT now, so game modes moved to M and the HUD cycles on H.
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_B:
_rain(500)
elif event.keycode == KEY_R:
_reset()
elif event.keycode == KEY_1:
_switch_mode(GameMode.Mode.FREE_PLAY)
elif event.keycode == KEY_2:
_switch_mode(GameMode.Mode.FIND_MISFILED)
elif event.keycode == KEY_F and _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
match (event as InputEventKey).keycode:
KEY_B:
_rain(500)
KEY_R:
_reset()
KEY_M:
_switch_mode(GameMode.Mode.FIND_MISFILED
if _game_mode.mode == GameMode.Mode.FREE_PLAY
else GameMode.Mode.FREE_PLAY)
KEY_H:
if _hud != null:
_hud.cycle()
KEY_F:
if _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
func _rain(n: int) -> void:
@ -352,6 +389,8 @@ func _reset() -> void:
for c in _world.get_children():
c.queue_free()
_smash_count = 0
_score = 0
_tally.clear()
await get_tree().process_frame
_build_rack(Vector3.ZERO)
# re-arm whatever mode we're in against the fresh set of records
@ -370,20 +409,35 @@ func _switch_mode(m: int) -> void:
# ---------------------------------------------------------------- HUD
func _setup_hud() -> void:
var layer := CanvasLayer.new()
add_child(layer)
_hud = Label.new()
_hud.position = Vector2(16, 12)
_hud.add_theme_font_size_override("font_size", 18)
_hud.add_theme_color_override("font_color", Color(1, 0.36, 0.62))
layer.add_child(_hud)
_hud = Hud.new()
add_child(_hud)
if _player != null:
_player.weapon_changed.connect(_on_weapon_changed)
func _on_weapon_changed(w: Weapon) -> void:
if _hud != null and w != null:
_hud.toast(w.display_name)
const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · B rain · R reset"
func _process(_dt: float) -> void:
if _hud == null:
return
var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size()
var c := _juice.combo() if _juice != null else 0
var combo_str := (" COMBO x%d" % c) if c > 1 else ""
var mode_line := _game_mode.hud_line() if _game_mode != null else ""
_hud.text = "FPS %d bodies %d smashed %d%s\n%s\nWASD move E grab drag ←→ pull LMB throw/punch G drop F report B rain R reset" % [
Engine.get_frames_per_second(), bodies, _smash_count, combo_str, mode_line]
var bodies := get_tree().get_nodes_in_group("smashable").size() \
+ get_tree().get_nodes_in_group("debris").size()
var w: Weapon = _player.weapon() if _player != null else null
_hud.feed({
"fps": Engine.get_frames_per_second(),
"bodies": bodies,
"smashed": _smash_count,
"score": _score,
"tally": _tally,
"combo": _juice.combo() if _juice != null else 0,
"combo_decay": _juice.combo_decay() if _juice != null else 0.0,
"weapon": w.display_name if w != null else "-",
"weapon_blurb": w.blurb if w != null else "",
"weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "",
"slot": _player.slot() if _player != null else 0,
"mode_line": _game_mode.hud_line() if _game_mode != null else "",
"keys": KEYS_LINE,
})

View File

@ -1,69 +0,0 @@
extends Resource
class_name MeleeAttack
## Config + logic for one melee swing (punch, kick, or a real weapon later).
## Swap the resource + viewmodel and you have a new weapon; movement never changes.
##
## Hit detection is a forgiving SPHERE query against the physics space, fired from
## the camera center (crosshair) so you don't have to pixel-aim. intersect_shape is
## NOT distance-sorted, so we pick the collider nearest the camera and act on it.
##
## Godot 4.7 GDScript 2.0. Verified: PhysicsShapeQueryParameters3D, SphereShape3D,
## PhysicsDirectSpaceState3D.intersect_shape, apply_impulse(impulse, rel_pos).
@export var reach: float = 1.8 ## distance in front of the camera the hit sphere sits
@export var radius: float = 0.38 ## fat sphere so melee is forgiving, not pixel-precise
@export var impulse: float = 6.0 ## smash strength handed to Smashable.smash()
@export var knockback: float = 3.0 ## central/off-center impulse for plain RigidBody3D props
@export var cooldown: float = 0.28 ## seconds between swings with this attack
## Run the hit test and apply effects.
## caster : the player (supplies the physics world + a self-exclude RID)
## origin : camera world position
## dir : camera forward (normalized), i.e. -camera.global_transform.basis.z
## Returns the node hit (Smashable or RigidBody3D) or null. Safe to call only from
## _physics_process — that's where direct_space_state is guaranteed valid.
func strike(caster: Node3D, origin: Vector3, dir: Vector3, exclude: Array = []) -> Node:
var space := caster.get_world_3d().direct_space_state
if space == null:
return null
var sphere := SphereShape3D.new()
sphere.radius = radius
var params := PhysicsShapeQueryParameters3D.new()
params.shape = sphere
params.transform = Transform3D(Basis.IDENTITY, origin + dir * reach)
params.collide_with_bodies = true
params.collide_with_areas = false
params.exclude = exclude # [caster.get_rid()] so the fat sphere never self-hits
var hits := space.intersect_shape(params, 8)
if hits.is_empty():
return null
# intersect_shape is unsorted — pick the collider nearest the camera so a swing
# hits the first thing in front, not something behind it.
var best: Node = null
var best_d := INF
for h in hits:
var col = h.get("collider")
if col == null or not (col is Node3D):
continue
var d: float = (col.global_position - origin).length_squared()
if d < best_d:
best_d = d
best = col
if best == null:
return null
# Smashable extends RigidBody3D, so this check MUST come first or we'd only
# knock it around and never shatter it.
if best is Smashable:
best.smash(dir * impulse)
elif best is RigidBody3D:
# off-center impulse (relative to center of mass) so props tumble, not glide
var hit_pos: Vector3 = origin + dir * reach
best.apply_impulse(dir * knockback, hit_pos - best.global_position)
return best

View File

@ -1 +0,0 @@
uid://d4anp68080714

View File

@ -1,15 +1,17 @@
extends CharacterBody3D
class_name Player
## First-person player for Destroyulator. Built entirely in code so Main.gd can
## spawn it where the old fixed Camera3D was and reuse `player.camera` for the
## crosshair raycast. Mouse-captured look, WASD move, Space jump; L-click PUNCH,
## R-click KICK; Esc releases the mouse (click recaptures).
## First-person player for Destroyulator. Built entirely in code so Main.gd can spawn it
## where the old fixed Camera3D was and reuse `player.camera` for the crosshair raycast.
## Mouse-captured look, WASD move, Space jump; Esc releases the mouse (click recaptures).
##
## Melee is a swappable MeleeAttack resource (reach/radius/impulse/knockback/cooldown)
## so real weapons slot in by swapping the resource + viewmodel — movement never
## changes. Hits are fired from screen-center (the mouse is captured, so its position
## is meaningless) and resolved in _physics_process, where direct_space_state is valid.
## Melee is a `Weapon` resource (stats + the weapon-vs-material matrix + a swing
## archetype) and the arms are a rigged `ViewModel`. Swapping weapon swaps both; movement
## never changes.
##
## The hit does NOT land on the click — it lands at `weapon.contact` through the swing
## animation. That single delay is most of why a sledgehammer feels different from a box
## cutter: you commit, then it connects.
##
## Godot 4.7 GDScript 2.0. No 3.x APIs: move_and_slide() takes no args and reads/writes
## `velocity`; get_gravity() supplies the project default; mouse_mode is a property.
@ -23,26 +25,31 @@ class_name Player
@export var eye_height: float = 1.6
var camera: Camera3D # Main reads this for the crosshair raycast
var grab: GrabController # the record grab/extract/throw hands (Main reads this)
var grab: GrabController # the record grab/extract/throw hands
var viewmodel: ViewModel # the arms + weapon rig
var _head: Node3D
var _hands_rig: Node3D # cosmetic viewmodel (hands + leg), swung on attack
var _r_hand: MeshInstance3D
var _l_hand: MeshInstance3D
var _leg: MeshInstance3D
var _swing_tween: Tween
var _pitch := 0.0
var _cd_left := 0.0
var _queued: MeleeAttack = null # buffered click, consumed next physics step
var _punch: MeleeAttack
var _kick: MeleeAttack
# --- loadout -------------------------------------------------------------
var _loadout: Array[Weapon] = []
var _slot := 0
var _prev_slot := 0
# resting local transforms of the viewmodel parts (relative to the camera)
const R_HAND_REST := Vector3(0.30, -0.32, -0.55)
const L_HAND_REST := Vector3(-0.30, -0.34, -0.58)
const LEG_REST := Vector3(0.06, -1.05, -0.42)
# --- swing scheduling ----------------------------------------------------
var _queued := false # a click waiting for the cooldown
var _swing_left := -1.0 # seconds until this swing's contact frame
var _swinging: Weapon = null
# --- feel bookkeeping ----------------------------------------------------
var _look_delta := Vector2.ZERO # mouse motion this frame, fed to viewmodel sway
var _was_on_floor := true
var _fall_speed := 0.0
signal weapon_changed(w: Weapon)
signal hit_landed(node: Node, w: Weapon)
signal swung(w: Weapon)
func _ready() -> void:
# --- collision capsule (feet at the body origin) ---
@ -64,10 +71,15 @@ func _ready() -> void:
camera.name = "Camera3D"
_head.add_child(camera)
camera.current = true
camera.near = 0.03 # the viewmodel sits ~40 cm out; don't clip it
_loadout = Weapon.loadout()
viewmodel = ViewModel.new()
viewmodel.setup(camera) # parents itself to the camera
viewmodel.equip(_loadout[_slot])
_build_viewmodel()
_build_crosshair()
_make_attacks()
# the record ritual hands: a child node that owns a hold point under the camera
grab = GrabController.new()
@ -75,80 +87,39 @@ func _ready() -> void:
grab.setup(camera)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
weapon_changed.emit(_loadout[_slot])
# ---------------------------------------------------------------- attacks
func _make_attacks() -> void:
_punch = MeleeAttack.new()
_punch.reach = 1.7
_punch.radius = 0.34
_punch.impulse = 6.0
_punch.knockback = 2.5
_punch.cooldown = 0.26
# ---------------------------------------------------------------- loadout
func weapon() -> Weapon:
return _loadout[_slot]
_kick = MeleeAttack.new()
_kick.reach = 2.2
_kick.radius = 0.42
_kick.impulse = 10.0
_kick.knockback = 5.0
_kick.cooldown = 0.48
func loadout() -> Array[Weapon]:
return _loadout
# ---------------------------------------------------------------- viewmodel
func _build_viewmodel() -> void:
_hands_rig = Node3D.new()
_hands_rig.name = "ViewModel"
camera.add_child(_hands_rig)
func slot() -> int:
return _slot
var mat := StandardMaterial3D.new()
# semi-opaque: BOTH the alpha mode AND alpha<1 are required (alpha alone renders opaque)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.86, 0.62, 0.5, 0.55)
mat.no_depth_test = true # hands draw over world geo, never clip
mat.render_priority = 1
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.roughness = 0.85
func select(i: int) -> void:
if i < 0 or i >= _loadout.size() or i == _slot:
return
if viewmodel != null and viewmodel.is_swapping():
return
_prev_slot = _slot
_slot = i
_queued = false
_swing_left = -1.0
_swinging = null
if viewmodel != null:
viewmodel.request_swap(_loadout[_slot])
weapon_changed.emit(_loadout[_slot])
_r_hand = _make_capsule(mat)
_l_hand = _make_capsule(mat)
_leg = _make_leg(mat)
_hands_rig.add_child(_r_hand)
_hands_rig.add_child(_l_hand)
_hands_rig.add_child(_leg)
func cycle(step: int) -> void:
select(posmod(_slot + step, _loadout.size()))
_r_hand.position = R_HAND_REST
_l_hand.position = L_HAND_REST
_leg.position = LEG_REST
_r_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(-12))
_l_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(12))
_leg.rotation = Vector3(deg_to_rad(-90), 0.0, 0.0)
func _make_capsule(mat: StandardMaterial3D) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.055
cap.height = 0.20 # full height incl. the hemispherical caps
mi.mesh = cap
mi.material_override = mat
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
return mi
func _make_leg(mat: StandardMaterial3D) -> MeshInstance3D:
var shin := MeshInstance3D.new()
var cap := CapsuleMesh.new()
cap.radius = 0.075
cap.height = 0.34
shin.mesh = cap
shin.material_override = mat
shin.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var foot := MeshInstance3D.new()
var box := BoxMesh.new()
box.size = Vector3(0.11, 0.07, 0.22)
foot.mesh = box
foot.material_override = mat
foot.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
foot.position = Vector3(0.0, -0.20, 0.09)
shin.add_child(foot)
return shin
func quick_swap() -> void:
select(_prev_slot)
# ---------------------------------------------------------------- crosshair
func _build_crosshair() -> void:
var layer := CanvasLayer.new()
layer.name = "CrosshairLayer"
@ -168,62 +139,87 @@ func _build_crosshair() -> void:
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var mm := event as InputEventMouseMotion
_look_delta += mm.relative * 0.01
# while pulling a disc out, horizontal drag slides the disc (yaw is suspended);
# vertical still looks up/down so you're never fully locked.
if grab != null and grab.is_extracting():
grab.feed_drag(event.relative.x)
grab.feed_drag(mm.relative.x)
else:
rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only
rotate_y(-mm.relative.x * mouse_sensitivity) # yaw on the body only
_pitch = clamp(
_pitch - event.relative.y * mouse_sensitivity,
_pitch - mm.relative.y * mouse_sensitivity,
deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg))
_head.rotation.x = _pitch # pitch on the head only
return
if event is InputEventMouseButton and event.pressed:
var mb := event as InputEventMouseButton
if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; this one isn't an attack
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; not an attack
return
if event.button_index == MOUSE_BUTTON_LEFT:
# holding a record? LMB throws the disc / trashes the sleeve, not a punch.
if grab != null and grab.is_holding():
grab.primary()
else:
_queue_attack(_punch)
elif event.button_index == MOUSE_BUTTON_RIGHT:
_queue_attack(_kick)
match mb.button_index:
MOUSE_BUTTON_LEFT:
# holding a record? LMB throws the disc / trashes the sleeve, not a swing.
if grab != null and grab.is_holding():
grab.primary()
else:
_queued = true
MOUSE_BUTTON_WHEEL_UP:
cycle(-1)
MOUSE_BUTTON_WHEEL_DOWN:
cycle(1)
return
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
var k := (event as InputEventKey).keycode
if k == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.keycode == KEY_E and grab != null:
elif k == KEY_E and grab != null:
grab.try_grab() # grab the record under the crosshair
elif event.keycode == KEY_G and grab != null:
elif k == KEY_G and grab != null:
grab.drop() # drop / trash whatever's in hand
func _queue_attack(atk: MeleeAttack) -> void:
# buffer the click; the hit resolves in _physics_process where the space state is valid.
if _cd_left <= 0.0:
_queued = atk
elif k == KEY_Q:
quick_swap()
elif k >= KEY_1 and k <= KEY_6:
select(k - KEY_1)
# ---------------------------------------------------------------- physics / movement
func _physics_process(delta: float) -> void:
_cd_left = max(0.0, _cd_left - delta)
# resolve a buffered melee swing
if _queued != null and _cd_left <= 0.0:
var atk := _queued
_queued = null
_cd_left = atk.cooldown
var origin := camera.global_position
var dir := -camera.global_transform.basis.z # camera forward = crosshair
atk.strike(self, origin, dir, [get_rid()])
_swing_viewmodel(atk == _kick)
# --- start a swing when the cooldown allows ---
if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()):
_queued = false
var w := weapon()
_cd_left = w.cooldown
_swinging = w
_swing_left = w.swing_time * w.contact
if viewmodel != null:
viewmodel.start_swing()
swung.emit(w)
# --- land the hit at the contact frame, not on the click ---
if _swing_left >= 0.0:
_swing_left -= delta
if _swing_left <= 0.0:
var w := _swinging
_swing_left = -1.0
_swinging = null
if w != null:
var hit := _strike(w)
if hit != null:
hit_landed.emit(hit, w)
# gravity (only off the floor, or it accumulates and you rocket downward)
if not is_on_floor():
velocity += get_gravity() * delta
_fall_speed = maxf(_fall_speed, -velocity.y)
elif not _was_on_floor:
if viewmodel != null:
viewmodel.land(_fall_speed)
_fall_speed = 0.0
_was_on_floor = is_on_floor()
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
velocity.y = jump_velocity
@ -243,23 +239,57 @@ func _physics_process(delta: float) -> void:
move_and_slide()
# ---------------------------------------------------------------- cosmetic swing
func _swing_viewmodel(is_kick: bool) -> void:
if _swing_tween and _swing_tween.is_valid():
_swing_tween.kill() # interrupt any lingering swing so rapid clicks don't fight
if is_kick:
_leg.position = LEG_REST
var up := LEG_REST + Vector3(0.0, 0.66, -0.12)
_swing_tween = create_tween()
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_swing_tween.tween_property(_leg, "position", up, 0.10)
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_swing_tween.tween_property(_leg, "position", LEG_REST, 0.20)
else:
_r_hand.position = R_HAND_REST
var fwd := R_HAND_REST + Vector3(-0.06, 0.10, -0.34)
_swing_tween = create_tween()
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_swing_tween.tween_property(_r_hand, "position", fwd, 0.08)
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
_swing_tween.tween_property(_r_hand, "position", R_HAND_REST, 0.16)
func _process(dt: float) -> void:
if viewmodel != null:
var planar := Vector2(velocity.x, velocity.z).length()
viewmodel.drive(_look_delta, planar, is_on_floor(), dt)
_look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0))
# ---------------------------------------------------------------- the hit
## Forgiving SPHERE query from the camera centre (the crosshair), so melee doesn't need
## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider.
## Runs from _physics_process, where direct_space_state is valid.
func _strike(w: Weapon) -> Node:
var space := get_world_3d().direct_space_state
if space == null:
return null
var origin := camera.global_position
var dir := -camera.global_transform.basis.z
var sphere := SphereShape3D.new()
sphere.radius = w.radius
var params := PhysicsShapeQueryParameters3D.new()
params.shape = sphere
params.transform = Transform3D(Basis.IDENTITY, origin + dir * w.reach)
params.collide_with_bodies = true
params.collide_with_areas = false
params.exclude = [get_rid()]
var hits := space.intersect_shape(params, 8)
if hits.is_empty():
return null
var best: Node = null
var best_d := INF
for h in hits:
var c = h.get("collider")
if c == null or not (c is Node3D):
continue
var d: float = (c.global_position - origin).length_squared()
if d < best_d:
best_d = d
best = c
if best == null:
return null
# Smashable extends RigidBody3D, so this branch MUST come first or we'd only shove
# props around and never break them.
if best is Smashable:
var s := best as Smashable
s.smash(dir * w.impulse, w.damage_against(s.kind))
elif best is RigidBody3D:
var hit_pos: Vector3 = origin + dir * w.reach
(best as RigidBody3D).apply_impulse(dir * w.knockback,
hit_pos - (best as RigidBody3D).global_position)
return best

View File

@ -10,30 +10,40 @@ class_name Smashable
## hard enough — that's the satisfying TAIL of the cascade: the record survives the
## tumble out of its jacket, then cracks a beat later when it smacks the floor.
@export_enum("wood", "cardboard", "vinyl", "glass", "steel") var kind: String = "wood"
@export_enum("wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper") var kind: String = "wood"
@export var start_frozen: bool = false
## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are
## the real shards. When set, shatter() spawns those instead of primitive cubes. Main
## auto-assigns it from a `<name>.fractured.glb` next to the intact GLB.
@export var fractured_scene: PackedScene = null
## Hits to destroy. 1 = normal (one smash breaks it). The printer boss takes several.
@export var hits_to_break: int = 1
## Boss props get a bigger death burst (more juice particles) + survive extra hits.
## Scales this prop's hit points above its material default. The printer boss is 5.0.
@export var toughness_scale: float = 1.0
## Boss props get a bigger death burst (more juice particles).
@export var is_boss: bool = false
var _hits_taken := 0
var _hp := 1.0
var _hp_max := 1.0
# per-material behaviour — this table is where "material identity" is authored.
# brittle_speed 0.0 == not brittle (won't self-shatter on impact).
# hp ........... damage it soaks. A swing deals Weapon.power * Weapon.vs[kind], so
# this is the other half of the weapon-vs-material matrix.
# brittle_speed 0.0 == not brittle (won't self-shatter on impact).
const PROFILES := {
"wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05},
"cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0},
"vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12},
"glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0},
"steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25},
"wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05, "hp": 2.2},
"cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.6},
"vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12, "hp": 0.9},
"glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0, "hp": 1.0},
"steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25, "hp": 4.5},
"plastic": {"color": Color(0.82, 0.78, 0.70), "shards": 6, "brittle_speed": 0.0, "bounce": 0.15, "hp": 1.4},
"paper": {"color": Color(0.92, 0.90, 0.84), "shards": 3, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.3},
}
signal smashed(kind: String, at: Vector3, shard_count: int)
## A hit landed but this weapon can't meaningfully hurt this material — the
## box-cutter-on-a-filing-cabinet clank. Juice turns it into a sound + a small shake.
signal resisted(kind: String, at: Vector3)
## A hit that hurt but didn't kill. `frac` is remaining hp, 0..1.
signal damaged(kind: String, at: Vector3, frac: float)
var _shattered := false
var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves
@ -47,6 +57,8 @@ var _released := false # so a piece is only released once (idempotent, cycle-s
func _ready() -> void:
add_to_group("smashable")
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
_hp_max = float(p["hp"]) * maxf(toughness_scale, 0.01)
_hp = _hp_max
physics_material_override = PhysicsMaterial.new()
physics_material_override.bounce = p["bounce"]
if start_frozen:
@ -68,18 +80,38 @@ func _on_body_entered(_body: Node) -> void:
if _speed >= float(p["brittle_speed"]):
shatter(Vector3.ZERO)
## Called by the hammer. Bosses (hits_to_break > 1) soak hits and visibly rock before
## the final blow shatters them; a normal prop (hits_to_break == 1) breaks on hit one.
func smash(impulse: Vector3) -> void:
## Take a swing. `damage` is what the weapon deals to THIS material (see Weapon.vs);
## the default of -1 means "whatever it takes", which keeps thrown-object and scripted
## smashes working without needing a weapon.
##
## Three outcomes, and the middle one is the point of the whole matrix:
## damage kills it ....... shatter
## damage dents it ....... knockback + `damaged`, so heavy props are a fight
## damage is futile ...... `resisted` — the clank that tells you to switch weapon
func smash(impulse: Vector3, damage: float = -1.0) -> void:
if _shattered:
return
_hits_taken += 1
if _hits_taken < hits_to_break:
# survives this hit — a physical knockback so the boss reacts to the beating
sleeping = false
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
if damage < 0.0:
shatter(impulse)
return
shatter(impulse)
if damage < _hp_max * 0.10:
# too weak to matter: bounce off, make a noise, leave the prop intact
sleeping = false
apply_central_impulse(impulse.limit_length(4.0) * 0.10)
resisted.emit(kind, global_position)
return
_hp -= damage
if _hp <= 0.0:
shatter(impulse)
return
# survived — a physical knockback so it visibly reacts to the beating
sleeping = false
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
damaged.emit(kind, global_position, clampf(_hp / _hp_max, 0.0, 1.0))
## Remaining hp as 0..1, for HUD damage reads.
func hp_frac() -> float:
return clampf(_hp / maxf(_hp_max, 0.001), 0.0, 1.0)
func shatter(impulse: Vector3) -> void:
if _shattered:

501
game/scripts/ViewModel.gd Normal file
View File

@ -0,0 +1,501 @@
extends Node3D
class_name ViewModel
## The first-person viewmodel: real rigged hands holding a real weapon, and every bit
## of motion that sells a swing.
##
## HOW THE ARMS WORK (the bit worth knowing before editing)
## `fps_arms.glb` is a mixamorig-rigged pair in a T-pose. Rather than solve IK to put a
## hand somewhere, the rig is instanced ONCE PER HAND and each instance is *placed* by
## transform so its own hand bone lands exactly on the grip. The other side's meshes are
## hidden. That works because in first person you only ever see forearm and hand — a
## straight forearm coming in from off-screen is exactly what an FPS arm looks like — and
## it buys exact, tunable grip placement for two lines of matrix maths instead of an IK
## solver that would need tuning anyway. Fingers ARE posed for real, per weapon, because
## finger curl is a single rotation per joint and the rig has all 20 bones.
##
## MOTION LAYERS, all composed onto `_rig` each frame:
## sway ....... the rig lags behind mouse-look, then springs back (weight)
## bob ........ figure-8 while walking, scaled by speed and weapon heft
## breathe .... a small idle drift so a standing player is never perfectly still
## swing ...... keyframed windup -> strike -> recover arc, per weapon archetype
## dip ........ a landing compression when you hit the floor
##
## Godot 4.7 GDScript 2.0.
const ARMS_GLB := "res://assets/viewmodel/fps_arms.glb"
const BONE_HAND_R := "mixamorig_RightHand"
const BONE_HAND_L := "mixamorig_LeftHand"
const FINGERS_R := ["mixamorig_RightHandIndex", "mixamorig_RightHandMiddle",
"mixamorig_RightHandRing", "mixamorig_RightHandPinky"]
const FINGERS_L := ["mixamorig_LeftHandIndex", "mixamorig_LeftHandMiddle",
"mixamorig_LeftHandRing", "mixamorig_LeftHandPinky"]
const THUMB_R := "mixamorig_RightHandThumb"
const THUMB_L := "mixamorig_LeftHandThumb"
## Whole-viewmodel scale. A cricket bat really is 85 cm and really is held 45 cm from
## your eye, and at a 75-degree FOV that fills the screen — which is why every FPS shrinks
## its viewmodel rather than rendering it life-size. This is that shrink.
@export var vm_scale: float = 0.52
## Where the weapon grip sits relative to the camera, at rest. +X right, +Y up, -Z fwd.
@export var grip_rest := Vector3(0.30, -0.32, -0.56)
## Rest orientation of the grip frame (degrees). The weapon's shaft runs +Y out of the
## grip, so this tips it up-and-right and leans it away from the camera — far enough
## right that a 45 cm cricket bat blade isn't parked over the crosshair.
@export var grip_rest_rot := Vector3(-30.0, 16.0, -40.0)
# ---------------------------------------------------------------- nodes
var _rig: Node3D # everything animates on this
var _grip: Node3D # the weapon's grip frame; hands are placed off it
var _arm_r: Node3D
var _arm_l: Node3D
var _skel_r: Skeleton3D
var _skel_l: Skeleton3D
var _hand_in_arm_r := Transform3D.IDENTITY # hand bone rest, in its instance's space
var _hand_in_arm_l := Transform3D.IDENTITY
var _bore_local_r := Vector3.RIGHT # grip axis, in hand-bone space (measured)
var _bore_local_l := Vector3.RIGHT
var _forearm_local_r := Vector3.UP # elbow->hand, in hand-bone space (measured)
var _forearm_local_l := Vector3.UP
var _palm_local_r := Vector3.ZERO # wrist->middle knuckle, in hand-bone space
var _palm_local_l := Vector3.ZERO
var _weapon_node: Node3D = null
var _weapon: Weapon = null
# ---------------------------------------------------------------- motion state
var _sway := Vector2.ZERO # smoothed look-delta the rig lags by
var _sway_vel := Vector2.ZERO
var _bob_t := 0.0
var _breathe_t := 0.0
var _dip := 0.0 # landing compression, decays to 0
var _swing_t := -1.0 # -1 = idle, else seconds into the swing
var _swap_t := -1.0 # weapon-change lower/raise
var _pending: Weapon = null
signal swing_contact ## the frame the swing actually connects
# ---------------------------------------------------------------- swing archetypes
# Keyframes are (time_fraction, position_offset, rotation_euler_degrees) on `_rig`.
# Interpolated with smoothstep; the strike segment is deliberately short so the arc
# reads as fast even when the whole animation is slow (the sledge).
const SWINGS := {
"overhead": [
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
[0.34, Vector3(0.02, 0.20, 0.14), Vector3(-62, -8, -6)],
[0.56, Vector3(-0.02, -0.16, -0.30), Vector3(72, 4, 4)],
[0.74, Vector3(-0.01, -0.06, -0.10), Vector3(30, 2, 2)],
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
],
"horizontal": [
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
[0.32, Vector3(0.22, 0.07, 0.16), Vector3(-12, -54, -18)],
[0.58, Vector3(-0.24, -0.05, -0.24), Vector3(6, 58, 24)],
[0.76, Vector3(-0.09, -0.02, -0.06), Vector3(2, 24, 10)],
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
],
"jab": [
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
[0.28, Vector3(0.03, -0.05, 0.10), Vector3(-14, 6, 0)],
[0.50, Vector3(-0.02, 0.02, -0.30), Vector3(10, -6, 0)],
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
],
"thrust": [
[0.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
[0.34, Vector3(0.02, 0.02, 0.14), Vector3(-8, 10, 0)],
[0.56, Vector3(-0.01, -0.02, -0.34), Vector3(6, -8, 0)],
[1.00, Vector3(0.00, 0.00, 0.00), Vector3(0, 0, 0)],
],
}
## Per-weapon hand placement, in the grip frame. `up` slides a hand along the shaft
## (+Y is toward the head), `rot` orients the fist around it, `curl` closes the fingers.
const GRIPS := {
"fists": {"r_up": 0.00, "l_up": 0.00, "curl": 1.00, "thumb": 0.85, "one_hand": true},
"cutter": {"r_up": 0.02, "l_up": 0.00, "curl": 0.92, "thumb": 0.70, "one_hand": true},
"bat": {"r_up": 0.03, "l_up": 0.15, "curl": 0.95, "thumb": 0.80, "one_hand": true},
"crowbar": {"r_up": 0.02, "l_up": 0.18, "curl": 0.95, "thumb": 0.80, "one_hand": true},
"sledge": {"r_up": 0.00, "l_up": 0.26, "curl": 0.97, "thumb": 0.85, "one_hand": false},
"extinguisher": {"r_up": 0.02, "l_up": 0.20, "curl": 0.90, "thumb": 0.75, "one_hand": false},
}
## Where the elbow should sit relative to the hand, as a direction (elbow -> hand) in
## rig space. This is what stops the forearm lying across the screen: the arm has to
## arrive from below and outside, the way your own does. Mirrored in X for the left.
const FOREARM_DIR := Vector3(-0.30, 0.62, -0.72)
## The off hand when it isn't on the weapon: down, out, and mostly off the bottom edge.
const IDLE_OFF_POS := Vector3(-0.30, -0.44, -0.40)
const IDLE_OFF_SHAFT := Vector3(0.35, 0.55, -0.75) # a virtual "shaft" for the loose fist
# ---------------------------------------------------------------- setup
func setup(cam: Camera3D) -> void:
if cam == null:
return
cam.add_child(self)
scale = Vector3.ONE * vm_scale
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
_grip = Node3D.new()
_grip.name = "Grip"
_rig.add_child(_grip)
_grip.transform = Transform3D(
Basis.from_euler(Vector3(deg_to_rad(grip_rest_rot.x), deg_to_rad(grip_rest_rot.y),
deg_to_rad(grip_rest_rot.z))), grip_rest)
_arm_r = _spawn_arm(true)
_arm_l = _spawn_arm(false)
## One instance of the arms rig, with the other side's meshes hidden. Returns null if
## the asset is missing so the game still runs (you just get no hands).
func _spawn_arm(right: bool) -> Node3D:
if not ResourceLoader.exists(ARMS_GLB):
push_warning("[viewmodel] %s missing — running without hands" % ARMS_GLB)
return null
var packed := load(ARMS_GLB) as PackedScene
if packed == null:
return null
var inst: Node3D = packed.instantiate()
inst.name = "ArmR" if right else "ArmL"
_rig.add_child(inst)
# NOTE: do not scale this instance. The GLB's own `kachujin_rig` node already carries
# the 0.01 cm->m conversion, so the meshes are life-size as imported; scaling here
# would apply it twice.
var skel := _find_skeleton(inst)
var drop := ["ch01_hand_L", "arms_sleeve_L"] if right else ["ch01_hand_R", "arms_sleeve_R"]
for mi in _mesh_nodes(inst):
if mi.name in drop:
mi.visible = false
else:
_restyle(mi)
# a viewmodel must never be clipped by world geometry or lit like world geometry
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
if right:
_skel_r = skel
_hand_in_arm_r = _bone_rest_in(inst, skel, BONE_HAND_R)
_measure(inst, skel, true)
else:
_skel_l = skel
_hand_in_arm_l = _bone_rest_in(inst, skel, BONE_HAND_L)
_measure(inst, skel, false)
return inst
## Work out, IN HAND-LOCAL SPACE, two directions we later want to aim:
## bore ..... the axis a handle runs along inside the fist. Taken as pinky-knuckle ->
## index-knuckle, i.e. the axis the fingers literally wrap around, so it
## comes from the rig instead of from a guessed euler triple.
## forearm .. elbow -> hand, so the arm can be made to arrive from the shoulder.
## Both are unit vectors in the hand bone's own frame, which makes them independent of
## how the rig happens to be oriented in the file.
func _measure(inst: Node3D, skel: Skeleton3D, right: bool) -> void:
if skel == null:
return
var hand := _bone_rest_in(inst, skel, BONE_HAND_R if right else BONE_HAND_L)
var fore := _bone_rest_in(inst, skel,
"mixamorig_RightForeArm" if right else "mixamorig_LeftForeArm")
var idx := _bone_rest_in(inst, skel,
"mixamorig_RightHandIndex1" if right else "mixamorig_LeftHandIndex1")
var pky := _bone_rest_in(inst, skel,
"mixamorig_RightHandPinky1" if right else "mixamorig_LeftHandPinky1")
var mid := _bone_rest_in(inst, skel,
"mixamorig_RightHandMiddle1" if right else "mixamorig_LeftHandMiddle1")
var hb := hand.basis.orthonormalized().inverse()
var bore := hb * (idx.origin - pky.origin).normalized()
var farm := hb * (hand.origin - fore.origin).normalized()
# The hand BONE sits at the wrist, but a handle is held at the knuckles — seat the
# wrist on the shaft and the shaft ends up running past the fist instead of through
# it. Middle-knuckle offset is the correction.
var palm := hb * (mid.origin - hand.origin)
if right:
_bore_local_r = bore
_forearm_local_r = farm
_palm_local_r = palm
else:
_bore_local_l = bore
_forearm_local_l = farm
_palm_local_l = palm
## Two fixes to the donor asset.
##
## Sleeve: the Kachujin source is a fantasy warrior — red leather bracer, cross-lacing.
## Wrong game. Replaced with a flat staff-tee colour.
##
## Hands: keep the skin texture (it's the good part of the donor) but kill the shine.
## These meshes ship spec/gloss maps that Godot reads as metallic, which renders a hand
## as polished bronze. Forcing metallic off and roughness up makes it skin again.
func _restyle(mi: MeshInstance3D) -> void:
if mi.mesh == null:
return
if mi.name.begins_with("arms_sleeve"):
for s in mi.mesh.get_surface_count():
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.15, 0.15, 0.19)
m.roughness = 0.92
m.metallic = 0.0
mi.set_surface_override_material(s, m)
return
for s in mi.mesh.get_surface_count():
var src := mi.mesh.surface_get_material(s) as BaseMaterial3D
if src == null:
continue
var d := src.duplicate() as BaseMaterial3D
d.metallic = 0.0
d.metallic_texture = null
d.roughness = 0.82
d.roughness_texture = null
mi.set_surface_override_material(s, d)
func _find_skeleton(n: Node) -> Skeleton3D:
if n is Skeleton3D:
return n
for c in n.get_children():
var r := _find_skeleton(c)
if r != null:
return r
return null
func _mesh_nodes(n: Node, acc: Array = []) -> Array:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_mesh_nodes(c, acc)
return acc
## A bone's rest transform expressed in `root`'s local space, so we can invert it to
## work out where `root` has to sit for that bone to land on a target.
func _bone_rest_in(root: Node3D, skel: Skeleton3D, bone: String) -> Transform3D:
if skel == null:
return Transform3D.IDENTITY
var idx := skel.find_bone(bone)
if idx < 0:
push_warning("[viewmodel] bone %s not found" % bone)
return Transform3D.IDENTITY
var skel_in_root := root.global_transform.affine_inverse() * skel.global_transform
return skel_in_root * skel.get_bone_global_rest(idx)
# ---------------------------------------------------------------- weapon
func equip(w: Weapon) -> void:
_weapon = w
if _weapon_node != null:
_weapon_node.queue_free()
_weapon_node = null
if w != null and w.mesh_path != "" and ResourceLoader.exists(w.mesh_path):
var packed := load(w.mesh_path) as PackedScene
if packed != null:
_weapon_node = packed.instantiate()
_grip.add_child(_weapon_node)
for mi in _mesh_nodes(_weapon_node):
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_pose_hands()
## Start a swap: the current weapon lowers, then `w` is equipped and raised.
func request_swap(w: Weapon) -> void:
_pending = w
_swap_t = 0.0
func current() -> Weapon:
return _weapon
# ---------------------------------------------------------------- posing
func _pose_hands() -> void:
var g: Dictionary = GRIPS.get(_weapon.id if _weapon else "fists", GRIPS["fists"])
var curl: float = float(g["curl"])
var thumb: float = float(g["thumb"])
var one_hand: bool = bool(g["one_hand"])
_place_hand(_arm_r, _hand_in_arm_r, float(g["r_up"]), true)
_curl_fingers(_skel_r, FINGERS_R, THUMB_R, curl, thumb)
# a one-handed weapon still shows the off hand, just idling out of the way
var l_up: float = float(g["l_up"])
if _arm_l != null:
_arm_l.visible = true
if one_hand:
_place_off_hand(_arm_l, _hand_in_arm_l, false)
_curl_fingers(_skel_l, FINGERS_L, THUMB_L, 0.62, 0.55)
else:
_place_hand(_arm_l, _hand_in_arm_l, l_up, false)
_curl_fingers(_skel_l, FINGERS_L, THUMB_L, curl, thumb)
## Put `arm`'s fist on the weapon shaft, `up` metres along it from the grip.
##
## Two aims, solved together: the fist's bore lines up with the shaft, and the forearm
## runs back toward where that shoulder would be. Building a frame from each pair and
## mapping one onto the other satisfies both at once — no euler tuning, and it survives
## any change to `grip_rest_rot`.
func _place_hand(arm: Node3D, hand_rest: Transform3D, up: float, right: bool) -> void:
if arm == null:
return
var shaft := _grip.transform.basis.y.normalized()
var forearm := FOREARM_DIR
if not right:
forearm.x = -forearm.x
var b_local := _bore_local_r if right else _bore_local_l
var f_local := _forearm_local_r if right else _forearm_local_l
var basis := _frame(shaft, forearm.normalized()) * _frame(b_local, f_local).inverse()
var palm := _palm_local_r if right else _palm_local_l
# aim the KNUCKLES at the shaft, not the wrist
var pos := _grip.transform * Vector3(0.0, up, 0.0) - basis * palm
_seat(arm, hand_rest, Transform3D(basis, pos))
## The idle off hand: a loose fist down and out of the sight line.
func _place_off_hand(arm: Node3D, hand_rest: Transform3D, right: bool) -> void:
if arm == null:
return
var forearm := FOREARM_DIR
if not right:
forearm.x = -forearm.x
var shaft := IDLE_OFF_SHAFT
if not right:
shaft.x = -shaft.x
var b_local := _bore_local_r if right else _bore_local_l
var f_local := _forearm_local_r if right else _forearm_local_l
var basis := _frame(shaft.normalized(), forearm.normalized()) \
* _frame(b_local, f_local).inverse()
var pos := IDLE_OFF_POS
if right:
pos.x = -pos.x
pos -= basis * (_palm_local_r if right else _palm_local_l)
_seat(arm, hand_rest, Transform3D(basis, pos))
## An orthonormal basis whose X is `a` and whose Y is `b` made perpendicular to it.
## Used on both sides of the mapping, so only the RELATIVE geometry of the two
## directions matters — which is exactly the constraint a grip expresses.
func _frame(a: Vector3, b: Vector3) -> Basis:
var x := a.normalized()
var y := b - x * b.dot(x)
if y.length() < 1e-4: # degenerate: b parallel to a
y = x.cross(Vector3.UP)
if y.length() < 1e-4:
y = x.cross(Vector3.RIGHT)
y = y.normalized()
return Basis(x, y, x.cross(y))
## Move `arm` so its hand bone lands exactly on `target` (both in _rig space).
##
## `hand_rest` carries the GLB's internal 0.01 scale in its basis, so inverting it raw
## would scale the whole arm by 100. Orthonormalising first keeps the placement rigid:
## the arm stays life-size and only its position/orientation change.
func _seat(arm: Node3D, hand_rest: Transform3D, target: Transform3D) -> void:
var rigid := hand_rest
rigid.basis = rigid.basis.orthonormalized()
arm.transform = target * rigid.affine_inverse()
## Curl every finger joint by a fraction of its comfortable range. Mixamo finger bones
## run +Y down the bone and flex about local Z, so one rotation per joint is all it takes.
func _curl_fingers(skel: Skeleton3D, prefixes: Array, thumb_prefix: String,
curl: float, thumb: float) -> void:
if skel == null:
return
const JOINT_DEG := [58.0, 62.0, 48.0] # proximal, middle, distal
for pre in prefixes:
for j in range(3):
var idx := skel.find_bone("%s%d" % [pre, j + 1])
if idx < 0:
continue
skel.set_bone_pose_rotation(idx, Quaternion(Vector3(0, 0, 1),
deg_to_rad(JOINT_DEG[j] * curl)))
const THUMB_DEG := [42.0, 46.0, 34.0]
for j in range(3):
var idx := skel.find_bone("%s%d" % [thumb_prefix, j + 1])
if idx < 0:
continue
skel.set_bone_pose_rotation(idx, Quaternion(Vector3(0, 0, 1),
deg_to_rad(THUMB_DEG[j] * thumb)))
# ---------------------------------------------------------------- driving
## Player calls this every frame with its state.
func drive(look_delta: Vector2, planar_speed: float, on_floor: bool, dt: float) -> void:
var heft: float = _weapon.heft if _weapon != null else 1.0
# --- sway: the rig lags the camera, then springs back ---
var target := Vector2(clampf(-look_delta.x, -1.0, 1.0), clampf(-look_delta.y, -1.0, 1.0))
var stiffness := 42.0 / maxf(heft, 0.3)
_sway_vel += (target * 0.030 - _sway) * stiffness * dt
_sway_vel *= exp(-9.0 * dt)
_sway += _sway_vel
# --- bob: figure-8, amplitude from speed, slowed by a heavy weapon ---
var speed01 := clampf(planar_speed / 5.0, 0.0, 1.4)
_bob_t += dt * (7.4 + speed01 * 2.2) * (1.0 if on_floor else 0.0)
var bob_amp := speed01 * 0.022 * (1.0 if on_floor else 0.0)
var bob := Vector3(sin(_bob_t) * bob_amp,
-absf(cos(_bob_t)) * bob_amp * 1.15, 0.0)
_breathe_t += dt * 1.15
var breathe := Vector3(sin(_breathe_t * 0.7) * 0.0035,
sin(_breathe_t) * 0.0042, 0.0)
_dip = move_toward(_dip, 0.0, dt * 0.9)
# --- compose ---
var pos := Vector3(_sway.x, _sway.y - _dip, 0.0) + bob + breathe
var rot := Vector3(-_sway.y * 5.4, _sway.x * 6.2, -_sway.x * 8.0)
rot.x += sin(_bob_t) * speed01 * 1.1
if _swing_t >= 0.0:
var st: float = _weapon.swing_time if _weapon != null else 0.4
_swing_t += dt
var frac := clampf(_swing_t / maxf(st, 0.01), 0.0, 1.0)
var kind: String = _weapon.swing if _weapon != null else "jab"
var kf: Array = SWINGS.get(kind, SWINGS["jab"])
var s := _sample(kf, frac)
pos += s[0]
rot += s[1]
if _swing_t >= st:
_swing_t = -1.0
if _swap_t >= 0.0:
_swap_t += dt
const SWAP_DOWN := 0.14
const SWAP_UP := 0.20
if _swap_t < SWAP_DOWN:
var k := _swap_t / SWAP_DOWN
pos += Vector3(0, -0.34 * k, 0)
rot += Vector3(46.0 * k, 0, 0)
elif _pending != null:
equip(_pending) # swap at the bottom of the arc
_pending = null
pos += Vector3(0, -0.34, 0)
rot += Vector3(46.0, 0, 0)
else:
var k := clampf((_swap_t - SWAP_DOWN) / SWAP_UP, 0.0, 1.0)
var e := 1.0 - (1.0 - k) * (1.0 - k)
pos += Vector3(0, -0.34 * (1.0 - e), 0)
rot += Vector3(46.0 * (1.0 - e), 0, 0)
if _swap_t >= SWAP_DOWN + SWAP_UP:
_swap_t = -1.0
_rig.position = pos
_rig.rotation = Vector3(deg_to_rad(rot.x), deg_to_rad(rot.y), deg_to_rad(rot.z))
## Interpolate a keyframe list at 0..1. Returns [position, rotation_degrees].
func _sample(kf: Array, t: float) -> Array:
for i in range(kf.size() - 1):
var a: Array = kf[i]
var b: Array = kf[i + 1]
if t >= float(a[0]) and t <= float(b[0]):
var span: float = maxf(float(b[0]) - float(a[0]), 0.0001)
var k: float = (t - float(a[0])) / span
k = k * k * (3.0 - 2.0 * k) # smoothstep
return [(a[1] as Vector3).lerp(b[1] as Vector3, k),
(a[2] as Vector3).lerp(b[2] as Vector3, k)]
var last: Array = kf[kf.size() - 1]
return [last[1], last[2]]
# ---------------------------------------------------------------- events
func start_swing() -> void:
_swing_t = 0.0
func is_swinging() -> bool:
return _swing_t >= 0.0
func is_swapping() -> bool:
return _swap_t >= 0.0
func land(force: float) -> void:
_dip = clampf(_dip + force * 0.06, 0.0, 0.11)

View File

@ -0,0 +1 @@
uid://bxay77q0hgm4x

185
game/scripts/Weapon.gd Normal file
View File

@ -0,0 +1,185 @@
extends Resource
class_name Weapon
## One weapon: its stats, its swing, and how it sits in the hands.
##
## Replaces MeleeAttack. The important new part is `vs` — the WEAPON vs MATERIAL
## matrix the founding doc asked for. A weapon no longer just "breaks things"; it does
## `power * vs[material]` damage against that material's toughness (Smashable.PROFILES
## .hp). That is what makes the loadout a decision instead of a skin:
##
## box cutter — shreds cardboard and paper, pings uselessly off a filing cabinet
## cricket bat — the all-rounder; snaps vinyl and glass, struggles with steel
## crowbar — the only mid-speed answer to steel
## sledgehammer— ends anything, but you swing it once a second
##
## Swing timing matters as much as damage: `contact` is how far into the animation the
## hit actually lands, so the sledge connects late and heavy while the cutter is instant.
##
## Godot 4.7 GDScript 2.0.
@export var id: String = "fists"
@export var display_name: String = "Fists"
@export_multiline var blurb: String = ""
## Viewmodel mesh; "" means bare hands.
@export var mesh_path: String = ""
# ---------------------------------------------------------------- hit shape
@export var reach: float = 1.7 ## metres in front of the camera the hit sphere sits
@export var radius: float = 0.34 ## fat sphere — melee is forgiving, not pixel-precise
@export var impulse: float = 6.0 ## physics shove applied to whatever is hit
@export var knockback: float = 2.5 ## off-centre impulse for plain (non-Smashable) bodies
# ---------------------------------------------------------------- damage
@export var power: float = 1.0 ## base damage, before the material multiplier
@export var vs: Dictionary = {} ## material -> multiplier (missing = 1.0)
# ---------------------------------------------------------------- feel
@export var cooldown: float = 0.30 ## seconds between swings
@export var swing: String = "horizontal" ## horizontal | overhead | thrust | jab
@export var swing_time: float = 0.42 ## full animation length
@export var contact: float = 0.34 ## fraction of swing_time at which the hit lands
@export var two_handed: bool = false
@export var heft: float = 1.0 ## scales bob/sway amplitude and landing dip
@export var shake: float = 0.20 ## trauma added to the camera on a connecting hit
## Damage this weapon does to one material.
func damage_against(kind: String) -> float:
return power * float(vs.get(kind, 1.0))
## True when this weapon is essentially useless against `kind` — drives the comedy
## "clank, nothing happened" feedback instead of a silent non-event.
func is_futile_against(kind: String, hp: float) -> bool:
return damage_against(kind) < hp * 0.10
# ---------------------------------------------------------------- the loadout
## Built in code (not .tres) so the whole matrix reads as one table you can tune in
## a single place. Order here is the order of the number keys.
static func loadout() -> Array[Weapon]:
var out: Array[Weapon] = []
var fists := Weapon.new()
fists.id = "fists"
fists.display_name = "Bare Hands"
fists.blurb = "Free. Fast. Regrettable."
fists.mesh_path = ""
fists.reach = 1.65
fists.radius = 0.34
fists.power = 1.0
fists.impulse = 5.0
fists.knockback = 2.2
fists.cooldown = 0.26
fists.swing = "jab"
fists.swing_time = 0.30
fists.contact = 0.30
fists.heft = 0.5
fists.shake = 0.14
fists.vs = {"cardboard": 1.6, "paper": 2.0, "glass": 1.2, "vinyl": 1.0,
"wood": 0.45, "steel": 0.25, "plastic": 0.8}
out.append(fists)
var cutter := Weapon.new()
cutter.id = "cutter"
cutter.display_name = "Box Cutter"
cutter.blurb = "Shreds paper and card. Does nothing to furniture. Nothing."
cutter.mesh_path = "res://assets/viewmodel/cutter.glb"
cutter.reach = 1.45
cutter.radius = 0.28
cutter.power = 0.55
cutter.impulse = 2.0
cutter.knockback = 1.0
cutter.cooldown = 0.15
cutter.swing = "jab"
cutter.swing_time = 0.22
cutter.contact = 0.36
cutter.heft = 0.35
cutter.shake = 0.06
cutter.vs = {"cardboard": 9.0, "paper": 14.0, "vinyl": 2.6, "plastic": 2.0,
"glass": 0.30, "wood": 0.14, "steel": 0.04}
out.append(cutter)
var bat := Weapon.new()
bat.id = "bat"
bat.display_name = "Cricket Bat"
bat.blurb = "The all-rounder. Vinyl and glass do not enjoy it."
bat.mesh_path = "res://assets/viewmodel/bat.glb"
bat.reach = 2.10
bat.radius = 0.40
bat.power = 2.4
bat.impulse = 8.0
bat.knockback = 4.0
bat.cooldown = 0.40
bat.swing = "horizontal"
bat.swing_time = 0.44
bat.contact = 0.36
bat.two_handed = false
bat.heft = 1.0
bat.shake = 0.22
bat.vs = {"vinyl": 2.1, "glass": 1.7, "cardboard": 1.4, "wood": 1.2,
"plastic": 1.3, "steel": 0.55, "paper": 0.6}
out.append(bat)
var crowbar := Weapon.new()
crowbar.id = "crowbar"
crowbar.display_name = "Crowbar"
crowbar.blurb = "Bites metal. The filing cabinet has met its match."
crowbar.mesh_path = "res://assets/viewmodel/crowbar.glb"
crowbar.reach = 1.95
crowbar.radius = 0.34
crowbar.power = 3.0
crowbar.impulse = 9.0
crowbar.knockback = 4.5
crowbar.cooldown = 0.46
crowbar.swing = "overhead"
crowbar.swing_time = 0.50
crowbar.contact = 0.40
crowbar.heft = 1.15
crowbar.shake = 0.26
crowbar.vs = {"steel": 1.9, "wood": 1.4, "glass": 1.3, "cardboard": 1.2,
"vinyl": 1.2, "plastic": 1.5, "paper": 0.5}
out.append(crowbar)
var sledge := Weapon.new()
sledge.id = "sledge"
sledge.display_name = "Sledgehammer"
sledge.blurb = "One swing a second. Worth the wait."
sledge.mesh_path = "res://assets/viewmodel/sledge.glb"
sledge.reach = 2.25
sledge.radius = 0.46
sledge.power = 6.5
sledge.impulse = 15.0
sledge.knockback = 8.0
sledge.cooldown = 0.85
sledge.swing = "overhead"
sledge.swing_time = 0.78
sledge.contact = 0.46
sledge.two_handed = true
sledge.heft = 1.9
sledge.shake = 0.46
sledge.vs = {"wood": 1.7, "glass": 1.5, "steel": 1.35, "plastic": 1.5,
"vinyl": 1.2, "cardboard": 0.75, "paper": 0.4}
out.append(sledge)
var ext := Weapon.new()
ext.id = "extinguisher"
ext.display_name = "Fire Extinguisher"
ext.blurb = "Heavy, awkward, and the best thing in the room to throw."
ext.mesh_path = "res://assets/viewmodel/extinguisher.glb"
ext.reach = 1.85
ext.radius = 0.44
ext.power = 4.5
ext.impulse = 13.0
ext.knockback = 7.0
ext.cooldown = 0.70
ext.swing = "horizontal"
ext.swing_time = 0.66
ext.contact = 0.42
ext.two_handed = true
ext.heft = 1.6
ext.shake = 0.38
ext.vs = {"glass": 1.9, "steel": 1.1, "wood": 1.0, "plastic": 1.4,
"vinyl": 1.1, "cardboard": 0.9, "paper": 0.4}
out.append(ext)
return out

View File

@ -0,0 +1 @@
uid://dt82xm6dc0rey

225
tools/gen_fps_arms.py Normal file
View File

@ -0,0 +1,225 @@
"""Cut a first-person ARMS rig out of the GODVERSE modular character kit.
Source: `character_kit_modular/exports/franken_kachujin_ch01hands.glb` the ch01 hands
grafted onto a Kachujin body, all deforming through the standard 65-bone `mixamorig:`
skeleton (see character_kit_modular/README.md, "GODVERSE socket standard v1").
We want the viewmodel, so we keep the two hand meshes plus the arm/sleeve region of the
body, drop the rest of the character, and shrink the textures. The ARMATURE IS KEPT
WHOLE AND UNRENAMED that is the kit's contract, and it is also what lets ViewModel.gd
pose individual finger bones (`mixamorig:RightHandIndex1` ) per weapon grip at runtime.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_fps_arms.py -- [--render]
Output: game/assets/viewmodel/fps_arms.glb
"""
import bpy
import bmesh
import math
import os
import sys
from mathutils import Vector
SRC = os.path.expanduser(
"~/Documents/character_kit_modular/exports/franken_kachujin_ch01hands.glb")
OUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"game", "assets", "viewmodel")
OUT = os.path.join(OUT_DIR, "fps_arms.glb")
RENDER = "--render" in sys.argv
# Previews are build artefacts, not game assets — keep them OUT of the Godot project
# or the importer picks them up as textures.
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
KEEP_MESHES = {"ch01_hand_L", "ch01_hand_R"}
# Body verts to keep: everything the arm bones drive (this is the shirt sleeve).
# Split per side, because the viewmodel instances this rig once PER HAND and hides the
# other side — which is impossible if both sleeves share one mesh.
SLEEVE_SIDES = {
"arms_sleeve_L": ["mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand"],
"arms_sleeve_R": ["mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand"],
}
WEIGHT_MIN = 0.35
TEX_MAX = 1024
def log(*a):
print("[arms]", *a)
def find_armature():
for o in bpy.data.objects:
if o.type == 'ARMATURE':
return o
return None
def _cut_to_groups(obj, group_names):
"""Delete every vertex not driven by `group_names`. Returns the surviving count."""
gidx = {g.name: g.index for g in obj.vertex_groups}
want = set(gidx[n] for n in group_names if n in gidx)
if not want:
return 0
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
deform = bm.verts.layers.deform.active
if deform is None:
bm.free()
raise SystemExit("mesh has no deform weights")
doomed = [v for v in bm.verts
if sum(wt for gi, wt in v[deform].items() if gi in want) < WEIGHT_MIN]
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
bm.to_mesh(obj.data)
n = len(bm.verts)
bm.free()
obj.data.update()
return n
def main():
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
log("importing", os.path.basename(SRC))
bpy.ops.import_scene.gltf(filepath=SRC)
arm = find_armature()
if arm is None:
raise SystemExit("no armature in source")
log("armature:", arm.name, "bones:", len(arm.data.bones))
# The kit README is explicit: imported GLBs arrive posed with an action assigned.
# Measuring or cutting before resetting to rest gives garbage.
arm.data.pose_position = 'REST'
if arm.animation_data:
arm.animation_data_clear()
for ob in bpy.data.objects:
if ob.animation_data:
ob.animation_data_clear()
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
log("meshes:", [(o.name, len(o.data.vertices)) for o in meshes])
body = None
for o in meshes:
if o.name in KEEP_MESHES:
continue
if o.name.lower().startswith("kachujin") and len(o.data.vertices) > 2000:
body = o # the torso/limbs mesh (the shirt)
else:
bpy.data.objects.remove(o, do_unlink=True)
kept = [o for o in bpy.data.objects if o.type == 'MESH']
log("kept meshes:", [o.name for o in kept])
# ---- trim the body down to a left and a right sleeve ------------------------
# bmesh rather than edit-mode operators: toggling modes to push a per-vertex
# selection through bpy.ops is fragile (it silently deleted the whole mesh), and
# the deform layer gives the weights directly.
if body is not None:
for name, groups in SLEEVE_SIDES.items():
side = body.copy()
side.data = body.data.copy()
side.name = name
bpy.context.collection.objects.link(side)
if _cut_to_groups(side, groups) == 0:
bpy.data.objects.remove(side, do_unlink=True)
log("sleeve", name, "empty — dropped")
else:
log("sleeve", name, "verts:", len(side.data.vertices))
bpy.data.objects.remove(body, do_unlink=True)
body = None
# ---- shrink textures -------------------------------------------------------
for img in list(bpy.data.images):
if img.users == 0:
bpy.data.images.remove(img)
continue
if max(img.size) > TEX_MAX:
w, h = img.size
s = TEX_MAX / float(max(w, h))
img.scale(max(int(w * s), 1), max(int(h * s), 1))
log("resized", img.name, "->", tuple(img.size))
# ---- report the arm rest pose so ViewModel.gd can be authored against it ----
ebones = arm.data.bones
for n in ("mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
"mixamorig:RightHandIndex1", "mixamorig:RightHandThumb1"):
b = ebones.get(n)
if b:
log("rest %-28s head=%s len=%.4f" % (
n, tuple(round(x, 4) for x in b.head_local), b.length))
total = sum(len(o.data.vertices) for o in bpy.data.objects if o.type == 'MESH')
log("total verts:", total)
# ---- export ----------------------------------------------------------------
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=OUT,
export_format='GLB',
use_selection=True,
export_apply=False, # keep the armature modifier live (skinned export)
export_skins=True,
export_animations=False,
export_yup=True,
export_image_format='JPEG',
)
log("wrote", OUT, "%.1f MB" % (os.path.getsize(OUT) / 1e6))
if RENDER:
render_preview()
def render_preview():
scene = bpy.context.scene
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
if want in engines:
scene.render.engine = want
break
scene.render.resolution_x = 800
scene.render.resolution_y = 800
if scene.world is None:
scene.world = bpy.data.worlds.new("preview")
scene.world.use_nodes = True
bg = scene.world.node_tree.nodes.get("Background")
if bg:
bg.inputs[0].default_value = (0.06, 0.06, 0.07, 1.0)
lo = Vector((1e9,) * 3)
hi = Vector((-1e9,) * 3)
for o in scene.objects:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
centre = (lo + hi) * 0.5
radius = max((hi - lo).length * 0.5, 0.01)
dist = radius * 2.6
d = Vector((0.55, -0.78, 0.30)).normalized()
bpy.ops.object.camera_add(location=tuple(centre + d * dist))
cam = bpy.context.active_object
cam.data.lens = 60
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y')
scene.camera = cam
for off, e, s in ((Vector((1.0, -1.0, 1.0)), 60.0, 1.2),
(Vector((-1.1, -0.4, 0.2)), 18.0, 1.8)):
bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist))
L = bpy.context.active_object
L.data.energy = e * (dist ** 2)
L.data.size = s * radius
scene.render.filepath = os.path.join(PREVIEW_DIR, "fps_arms.png")
bpy.ops.render.render(write_still=True)
main()

564
tools/gen_viewmodel.py Normal file
View File

@ -0,0 +1,564 @@
"""Generate Destroyulator's first-person WEAPON meshes as GLBs, procedurally, in Blender.
Why procedural instead of a generated mesh: a viewmodel weapon lives or dies on its
PIVOT. The grip has to sit exactly on the shaft axis, at the origin, or the hand floats
off the bat. That is a modelling constraint, not an art one, so it is authored in code
where the numbers are exact and re-runnable.
Hands and arms are NOT built here see tools/gen_fps_arms.py, which cuts a properly
rigged pair (with per-finger mixamorig bones) out of the GODVERSE modular character kit.
build_hand()/build_arm() survive below as a no-dependency fallback only.
/Applications/Blender.app/Contents/MacOS/Blender --background \
--python tools/gen_viewmodel.py -- [--render]
CONVENTIONS (shared with ViewModel.gd change both or neither)
* Author in Blender Z-up; the glTF exporter rewrites to Y-up, so Blender +Z becomes
glTF +Y. Everything below is described in BLENDER space.
* Weapons: grip centre at the ORIGIN, shaft running +Z (so the head is up). ViewModel
slides a second hand up a two-hander by translating along that axis alone.
* 1 unit = 1 m, matching the GLB convention in the repo README.
"""
import bpy
import bmesh
import math
import os
import sys
from mathutils import Vector, Matrix
OUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"game", "assets", "viewmodel")
RENDER = "--render" in sys.argv
# Previews are build artefacts, not game assets — keep them OUT of the Godot project
# or the importer picks them up as textures.
PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
# ---------------------------------------------------------------- scene helpers
def reset_scene():
bpy.ops.wm.read_factory_settings(use_empty=True)
def mat(name, color, rough=0.6, metal=0.0):
"""A plain Principled material. Colors are linear-ish sRGB values."""
m = bpy.data.materials.new(name)
m.use_nodes = True
bsdf = m.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Roughness"].default_value = rough
bsdf.inputs["Metallic"].default_value = metal
return m
def assign(obj, material):
obj.data.materials.clear()
obj.data.materials.append(material)
return obj
def shade_smooth(obj, angle_deg=35.0):
"""Smooth-by-angle. Blender 4.1+ dropped mesh.use_auto_smooth for an operator."""
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
try:
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle_deg))
except Exception:
bpy.ops.object.shade_smooth()
obj.select_set(False)
def bevel(obj, width=0.0015, segments=2):
m = obj.modifiers.new("bevel", "BEVEL")
m.width = width
m.segments = segments
m.limit_method = 'ANGLE'
m.angle_limit = math.radians(40)
def join(objs, name):
"""Join a list of meshes into one object (the first is the target)."""
objs = [o for o in objs if o is not None]
if not objs:
return None
if len(objs) == 1:
objs[0].name = name
return objs[0]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.object.join()
joined = bpy.context.view_layer.objects.active
joined.name = name
bpy.ops.object.select_all(action='DESELECT')
return joined
def apply_modifiers(obj):
bpy.context.view_layer.objects.active = obj
for m in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=m.name)
except Exception:
obj.modifiers.remove(m)
# ---------------------------------------------------------------- primitive builders
def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=16, r2=None):
"""Cylinder / tapered cone along +Z, centred on `loc`."""
if r2 is None:
bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts,
location=loc, rotation=rot)
else:
bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth,
vertices=verts, location=loc, rotation=rot)
return bpy.context.active_object
def box(size, loc=(0, 0, 0), rot=(0, 0, 0)):
# primitive_cube_add(size=s) already spans -s/2..+s/2, i.e. SIDE LENGTH s.
# Scaling by size/2 on top of that halves every box — which is exactly what
# detached the cricket bat's blade from its handle the first time round.
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(scale=True)
return o
def ball(r, loc=(0, 0, 0), segs=12, rings=8):
bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings,
location=loc)
return bpy.context.active_object
def segment_between(p0, p1, r0, r1, verts=10):
"""A tapered tube from p0 to p1. Used for arm bones."""
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
length = d.length
if length < 1e-6:
return None
o = cyl(r0, length, loc=(0, 0, 0), verts=verts, r2=r1)
quat = Vector((0, 0, 1)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
return o
def seg_box(p0, p1, thick, width, bev=0.006, segs=3):
"""A heavily-bevelled box spanning p0->p1. `thick` is the radial dimension,
`width` the across-the-knuckles one. This is the glove's building block: fat
rounded slabs, not thin tubes, so adjacent fingers read as one mitt."""
p0, p1 = Vector(p0), Vector(p1)
d = p1 - p0
length = d.length
if length < 1e-6:
return None
o = box((thick, length, width))
quat = Vector((0, 1, 0)).rotation_difference(d.normalized())
o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4()
bpy.ops.object.transform_apply(location=True, rotation=True)
bevel(o, width=bev, segments=segs)
apply_modifiers(o)
return o
# ---------------------------------------------------------------- the hand
#
# A chunky WORK GLOVE, not an anatomical hand. Two reasons: a viewmodel hand is 40 cm
# from the lens and half off-screen, where big readable masses beat fourteen thin
# phalanges (which just read as loose sausages); and a work glove is exactly right for
# a game about clocking out and wrecking your workplace.
#
# Grip bore axis = +Z, palm on -X, fingers wrap over +Y -> +X -> -Y.
# `curl` scales how far the fingers close: 1.0 = fist around a handle, 0.0 = flat.
FINGER_Z = [0.033, 0.011, -0.011, -0.033] # index -> pinky, along the grip
FINGER_LEN = [
(0.030, 0.023, 0.017),
(0.032, 0.025, 0.018),
(0.029, 0.023, 0.017),
(0.024, 0.019, 0.014),
]
FINGER_W = [0.022, 0.023, 0.022, 0.019] # nearly touching -> one mitt
FINGER_T = [0.021, 0.022, 0.021, 0.018] # radial thickness
GRIP_R = 0.019 # radius of the handle it closes on
def build_finger(z, lengths, width, thick, curl, start_ang_deg, ring_r):
"""FK chain of 3 fat phalanges wrapping clockwise around the grip axis."""
parts = []
ang = math.radians(start_ang_deg)
knuckle_ang = math.radians(start_ang_deg + 90.0)
pos = Vector((ring_r * math.cos(knuckle_ang), ring_r * math.sin(knuckle_ang), z))
curls = [math.radians(50.0), math.radians(52.0), math.radians(44.0)]
for i, ln in enumerate(lengths):
d = Vector((math.cos(ang), math.sin(ang), 0.0))
nxt = pos + d * ln
taper = 1.0 - i * 0.09
# overshoot each segment slightly so consecutive knuckles overlap and read solid
s = seg_box(pos - d * 0.004, nxt + d * 0.004,
thick * taper, width * taper, bev=0.0065, segs=3)
if s:
parts.append(s)
pos = nxt
ang -= curls[i] * curl
return parts
def build_hand(curl=1.0, name="hand_grip", skin=None, sleeve=None):
"""A right hand. The left is the same mesh mirrored by scale in Godot."""
parts = []
# --- palm: one rounded slab from the heel up to the knuckles ---
palm = box((0.034, 0.082, 0.101), loc=(-(GRIP_R + 0.014), 0.000, -0.002))
bevel(palm, width=0.014, segments=4)
apply_modifiers(palm)
parts.append(palm)
# heel, overlapping the palm so the two merge into one mass toward the wrist
heel = box((0.036, 0.062, 0.086), loc=(-(GRIP_R + 0.012), -0.012, -0.040))
bevel(heel, width=0.016, segments=4)
apply_modifiers(heel)
parts.append(heel)
# --- four fingers ---
ring_r = GRIP_R + FINGER_T[0] * 0.5
for i, z in enumerate(FINGER_Z):
start = 24.0 + (1.0 - curl) * 30.0 # opening the hand unwinds the start
parts += build_finger(z, FINGER_LEN[i], FINGER_W[i], FINGER_T[i],
curl, start, ring_r)
# --- thumb: two chunky segments crossing the front of the fingers ---
t_pos = Vector((-(GRIP_R + 0.008), -0.030, 0.048))
t_ang = math.radians(62.0 - 44.0 * curl)
for i, ln in enumerate((0.036, 0.028)):
d = Vector((math.cos(t_ang), math.sin(t_ang), 0.0))
nxt = t_pos + d * ln
s = seg_box(t_pos - d * 0.005, nxt + d * 0.004,
0.026 - i * 0.003, 0.027 - i * 0.003, bev=0.0085, segs=3)
if s:
parts.append(s)
t_pos = nxt
t_ang -= math.radians(44.0) * curl
hand = join(parts, name)
assign(hand, skin)
shade_smooth(hand, 34.0)
# --- wrist cuff: overlaps the heel so the glove meets the sleeve with no gap ---
cuff = cyl(0.040, 0.052, loc=(-0.014, -0.006, -0.066), r2=0.044, verts=18)
bevel(cuff, width=0.004, segments=2)
apply_modifiers(cuff)
assign(cuff, sleeve)
shade_smooth(cuff, 34.0)
cuff.name = name + "_cuff"
return hand, cuff
# ---------------------------------------------------------------- arm segments
def build_arm(skin, sleeve):
"""Upper arm and forearm, origin at the proximal joint, extending +Z."""
out = {}
# forearm: elbow -> wrist, 0.26 m, tapering, with the shirt cuff rolled at the elbow
fore = segment_between((0, 0, 0), (0, 0, 0.255), 0.049, 0.033, verts=14)
fore.name = "forearm"
assign(fore, skin)
shade_smooth(fore, 40.0)
roll = cyl(0.056, 0.052, loc=(0, 0, 0.012), r2=0.050, verts=16)
assign(roll, sleeve)
shade_smooth(roll, 40.0)
roll.name = "forearm_sleeve"
out["forearm"] = [fore, roll]
# upper arm: shoulder -> elbow, sleeved almost the whole way (short-sleeve tee)
upper = segment_between((0, 0, 0), (0, 0, 0.275), 0.058, 0.050, verts=14)
upper.name = "upperarm"
assign(upper, skin)
shade_smooth(upper, 40.0)
sleeve_m = segment_between((0, 0, -0.010), (0, 0, 0.150), 0.068, 0.058, verts=16)
sleeve_m.name = "upperarm_sleeve"
assign(sleeve_m, sleeve)
shade_smooth(sleeve_m, 40.0)
out["upperarm"] = [upper, sleeve_m]
return out
# ---------------------------------------------------------------- weapons
# Grip centre at origin, shaft +Z. Each returns a list of objects to join.
def w_bat(M):
"""Cricket bat — the founding doc's first weapon. Willow blade, rubber grip."""
parts = []
handle = cyl(0.0175, 0.30, loc=(0, 0, 0.09), r2=0.0195, verts=14)
parts.append(assign(handle, M["rubber"]))
for i in range(7): # grip rings
r = cyl(0.0198, 0.008, loc=(0, 0, -0.045 + i * 0.030), verts=14)
parts.append(assign(r, M["rubber"]))
shoulder = cyl(0.021, 0.075, loc=(0, 0, 0.276), r2=0.030, verts=14)
parts.append(assign(shoulder, M["willow"]))
blade = box((0.108, 0.042, 0.430), loc=(0, 0.004, 0.525))
bevel(blade, width=0.006, segments=2)
apply_modifiers(blade)
parts.append(assign(blade, M["willow"]))
spine = box((0.052, 0.030, 0.380), loc=(0, -0.030, 0.520)) # the ridge on the back
bevel(spine, width=0.010, segments=2)
apply_modifiers(spine)
parts.append(assign(spine, M["willow"]))
toe = box((0.108, 0.042, 0.030), loc=(0, 0.004, 0.742))
bevel(toe, width=0.012, segments=2)
apply_modifiers(toe)
parts.append(assign(toe, M["willow"]))
return parts
def w_sledge(M):
"""Sledgehammer — slow, enormous, the doc's answer to heavy wooden racks."""
parts = []
haft = cyl(0.019, 0.78, loc=(0, 0, 0.30), r2=0.024, verts=14)
parts.append(assign(haft, M["hickory"]))
for i in range(5):
r = cyl(0.0205, 0.010, loc=(0, 0, -0.075 + i * 0.036), verts=14)
parts.append(assign(r, M["rubber"]))
head = box((0.098, 0.098, 0.215), loc=(0, 0, 0.700), rot=(math.radians(90), 0, 0))
bevel(head, width=0.007, segments=2)
apply_modifiers(head)
parts.append(assign(head, M["steel"]))
collar = cyl(0.030, 0.055, loc=(0, 0, 0.678), verts=14)
parts.append(assign(collar, M["steel"]))
for s in (-1, 1): # slightly domed striking faces
face = cyl(0.043, 0.016, loc=(0, s * 0.109, 0.700),
rot=(math.radians(90), 0, 0), verts=16)
parts.append(assign(face, M["steel_dark"]))
return parts
def w_crowbar(M):
"""Crowbar — medium speed, bites into steel. Painted red, worn to bare metal."""
parts = []
shaft = cyl(0.0115, 0.62, loc=(0, 0, 0.18), verts=6) # hex stock
parts.append(assign(shaft, M["paint_red"]))
grip = cyl(0.0135, 0.14, loc=(0, 0, -0.055), verts=6)
parts.append(assign(grip, M["rubber"]))
# the curved claw: short chords stepping through ~85 degrees
ang = 0.0
pos = Vector((0, 0, 0.49))
for i in range(6):
d = Vector((0.0, math.sin(ang), math.cos(ang)))
nxt = pos + d * 0.030
s = segment_between(tuple(pos), tuple(nxt), 0.0115, 0.0112, verts=6)
if s:
parts.append(assign(s, M["steel"]))
pos = nxt
ang += math.radians(15.5)
claw = box((0.026, 0.050, 0.014), loc=(0, pos.y + 0.020, pos.z + 0.004),
rot=(math.radians(-22), 0, 0))
bevel(claw, width=0.003, segments=2)
apply_modifiers(claw)
parts.append(assign(claw, M["steel"]))
chisel = box((0.028, 0.011, 0.055), loc=(0, 0, -0.150), rot=(0, math.radians(9), 0))
bevel(chisel, width=0.003, segments=2)
apply_modifiers(chisel)
parts.append(assign(chisel, M["steel"]))
return parts
def w_cutter(M):
"""Box cutter — useless on furniture, devastating on cardboard and paper."""
parts = []
body = box((0.020, 0.038, 0.150), loc=(0, 0, 0.030))
bevel(body, width=0.005, segments=2)
apply_modifiers(body)
parts.append(assign(body, M["plastic_yellow"]))
track = box((0.022, 0.012, 0.100), loc=(0, 0.016, 0.030))
parts.append(assign(track, M["steel_dark"]))
slider = box((0.014, 0.010, 0.022), loc=(0, 0.024, 0.020))
parts.append(assign(slider, M["steel"]))
blade = box((0.010, 0.030, 0.062), loc=(0, 0.002, 0.132), rot=(math.radians(-8), 0, 0))
parts.append(assign(blade, M["blade"]))
tip = box((0.010, 0.020, 0.020), loc=(0, -0.006, 0.168), rot=(math.radians(-32), 0, 0))
parts.append(assign(tip, M["blade"]))
return parts
def w_extinguisher(M):
"""Fire extinguisher — heavy two-hand swing, and the best throwable in the store."""
parts = []
bottle = cyl(0.058, 0.330, loc=(0, 0, -0.035), verts=20)
parts.append(assign(bottle, M["paint_red"]))
for z, r in ((-0.200, 0.052), (0.130, 0.050)): # domed ends
d = ball(r, loc=(0, 0, z), segs=20, rings=10)
d.scale = (1.12, 1.12, 0.62)
bpy.ops.object.transform_apply(scale=True)
parts.append(assign(d, M["paint_red"]))
band = cyl(0.060, 0.045, loc=(0, 0, 0.020), verts=20)
parts.append(assign(band, M["paint_dark"]))
neck = cyl(0.020, 0.070, loc=(0, 0, 0.175), verts=14)
parts.append(assign(neck, M["steel_dark"]))
head = box((0.048, 0.062, 0.048), loc=(0, 0, 0.212))
bevel(head, width=0.005, segments=2)
apply_modifiers(head)
parts.append(assign(head, M["steel_dark"]))
lever = box((0.030, 0.100, 0.014), loc=(0, 0.034, 0.240), rot=(math.radians(10), 0, 0))
bevel(lever, width=0.004, segments=2)
apply_modifiers(lever)
parts.append(assign(lever, M["steel"]))
carry = box((0.028, 0.086, 0.013), loc=(0, 0.030, 0.196))
bevel(carry, width=0.004, segments=2)
apply_modifiers(carry)
parts.append(assign(carry, M["steel_dark"]))
# hose looping down the side
pos = Vector((0.0, 0.058, 0.196))
ang = math.radians(96)
for i in range(7):
d = Vector((0.0, math.cos(ang), -math.sin(ang)))
nxt = pos + d * 0.042
s = segment_between(tuple(pos), tuple(nxt), 0.010, 0.010, verts=6)
if s:
parts.append(assign(s, M["rubber"]))
pos = nxt
ang -= math.radians(13)
horn = cyl(0.014, 0.070, loc=tuple(pos + Vector((0, 0.012, -0.030))), r2=0.030, verts=14)
parts.append(assign(horn, M["paint_dark"]))
return parts
# ---------------------------------------------------------------- export / render
def export(obj_or_objs, name):
objs = obj_or_objs if isinstance(obj_or_objs, list) else [obj_or_objs]
objs = [o for o in objs if o is not None]
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
path = os.path.join(OUT_DIR, name + ".glb")
bpy.ops.export_scene.gltf(
filepath=path,
export_format='GLB',
use_selection=True,
export_apply=True,
export_yup=True,
)
tris = sum(len(o.data.loop_triangles) if o.data.loop_triangles else 0 for o in objs)
print("[gen] %-18s -> %s" % (name, os.path.basename(path)))
bpy.ops.object.select_all(action='DESELECT')
def render_preview(name):
"""Optional turnaround still, so the generator can be checked without Godot."""
scene = bpy.context.scene
# engine id moved around across versions (EEVEE -> EEVEE_NEXT -> EEVEE); take what exists
engines = scene.render.bl_rna.properties['engine'].enum_items.keys()
for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'):
if want in engines:
scene.render.engine = want
break
scene.render.resolution_x = 640
scene.render.resolution_y = 640
scene.render.film_transparent = False
# read_factory_settings(use_empty=True) leaves no world, so renders come out black
if scene.world is None:
scene.world = bpy.data.worlds.new("preview")
scene.world.use_nodes = True
bg = scene.world.node_tree.nodes.get("Background")
if bg is not None:
bg.inputs[0].default_value = (0.05, 0.05, 0.06, 1.0)
bg.inputs[1].default_value = 1.0
# --- frame whatever is in the scene: these assets range 13 cm to 85 cm, so a
# --- fixed camera either crops the sledge or loses the box cutter in the distance.
meshes = [o for o in scene.objects if o.type == 'MESH']
if not meshes:
return
lo = Vector((1e9, 1e9, 1e9))
hi = Vector((-1e9, -1e9, -1e9))
for o in meshes:
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z)))
hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z)))
centre = (lo + hi) * 0.5
radius = max((hi - lo).length * 0.5, 0.02)
dist = radius * 3.0
direction = Vector((0.62, -0.72, 0.36)).normalized()
bpy.ops.object.camera_add(location=tuple(centre + direction * dist))
cam = bpy.context.active_object
cam.data.lens = 55
# point the camera down its -Z at the centre
cam.rotation_mode = 'QUATERNION'
cam.rotation_quaternion = (-direction).to_track_quat('-Z', 'Y')
scene.camera = cam
# lights scale with the subject so a 13 cm cutter isn't lit like an 85 cm sledge
for offset, energy, size in ((Vector((1.1, -1.0, 1.2)), 55.0, 1.2),
(Vector((-1.2, -0.5, 0.2)), 16.0, 1.8)):
bpy.ops.object.light_add(type='AREA',
location=tuple(centre + offset * dist))
L = bpy.context.active_object
L.data.energy = energy * (dist ** 2)
L.data.size = size * radius
scene.render.filepath = os.path.join(PREVIEW_DIR, "" + name + ".png")
bpy.ops.render.render(write_still=True)
def materials():
return {
"skin": mat("skin", (0.78, 0.55, 0.44), rough=0.72),
"sleeve": mat("sleeve", (0.13, 0.13, 0.17), rough=0.85),
"willow": mat("willow", (0.80, 0.68, 0.47), rough=0.62),
"hickory": mat("hickory", (0.52, 0.36, 0.20), rough=0.68),
"rubber": mat("rubber", (0.07, 0.07, 0.08), rough=0.92),
"steel": mat("steel", (0.62, 0.63, 0.66), rough=0.34, metal=1.0),
"steel_dark": mat("steel_dark", (0.24, 0.25, 0.28), rough=0.46, metal=1.0),
"blade": mat("blade", (0.86, 0.87, 0.90), rough=0.16, metal=1.0),
"paint_red": mat("paint_red", (0.62, 0.06, 0.06), rough=0.40),
"paint_dark": mat("paint_dark", (0.10, 0.10, 0.12), rough=0.55),
"plastic_yellow": mat("plastic_yellow", (0.85, 0.66, 0.10), rough=0.44),
}
WEAPONS = {
"bat": w_bat,
"sledge": w_sledge,
"crowbar": w_crowbar,
"cutter": w_cutter,
"extinguisher": w_extinguisher,
}
def main():
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(PREVIEW_DIR, exist_ok=True)
# Hands and arms are NOT built here — they come from the GODVERSE modular
# character kit via tools/gen_fps_arms.py, which yields a properly rigged pair
# with per-finger mixamorig bones. The procedural glove this script used to emit
# was a stopgap and read as loose sausages next to the real thing.
# build_hand()/build_arm() are kept below as a no-dependency fallback.
# --- weapons ---
for name, builder in WEAPONS.items():
reset_scene()
M = materials()
parts = builder(M)
for p in parts:
apply_modifiers(p)
export(parts, name)
if RENDER:
render_preview(name)
print("[gen] done -> %s" % OUT_DIR)
main()