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>
3
.gitignore
vendored
@ -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/
|
||||
|
||||
112
game/README.md
@ -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 |
|
||||
| **1–6 · 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.
|
||||
|
||||
BIN
game/assets/viewmodel/bat.glb
Normal file
45
game/assets/viewmodel/bat.glb.import
Normal 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
|
||||
BIN
game/assets/viewmodel/crowbar.glb
Normal file
45
game/assets/viewmodel/crowbar.glb.import
Normal 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
|
||||
BIN
game/assets/viewmodel/cutter.glb
Normal file
45
game/assets/viewmodel/cutter.glb.import
Normal 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
|
||||
BIN
game/assets/viewmodel/extinguisher.glb
Normal file
45
game/assets/viewmodel/extinguisher.glb.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms.glb
Normal file
45
game/assets/viewmodel/fps_arms.glb.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
44
game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
@ -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
|
||||
BIN
game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
44
game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
43
game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Kachujin_diffuse.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
44
game/assets/viewmodel/fps_arms_Kachujin_diffuse.png.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
@ -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
|
||||
BIN
game/assets/viewmodel/fps_arms_Kachujin_normal.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
44
game/assets/viewmodel/fps_arms_Kachujin_normal.jpg.import
Normal 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
|
||||
BIN
game/assets/viewmodel/fps_arms_Kachujin_specular.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
43
game/assets/viewmodel/fps_arms_Kachujin_specular.jpg.import
Normal 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
|
||||
BIN
game/assets/viewmodel/sledge.glb
Normal file
45
game/assets/viewmodel/sledge.glb.import
Normal 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
@ -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()
|
||||
1
game/dev/DemoDriver.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://lveiga5ufogp
|
||||
6
game/dev/demo.tscn
Normal 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
@ -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
|
||||
1
game/dev/probe_arms.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bjtt6khwqooov
|
||||
52
game/dev/probe_scale.gd
Normal 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
|
||||
1
game/dev/probe_scale.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://de3cnb8nvrojh
|
||||
85
game/dev/probe_vm.gd
Normal 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
@ -0,0 +1 @@
|
||||
uid://cd2it4cwus0xw
|
||||
347
game/scripts/Hud.gd
Normal 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
@ -0,0 +1 @@
|
||||
uid://bovbusa0dmqe8
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
uid://d4anp68080714
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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)
|
||||
1
game/scripts/ViewModel.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bxay77q0hgm4x
|
||||
185
game/scripts/Weapon.gd
Normal 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
|
||||
1
game/scripts/Weapon.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dt82xm6dc0rey
|
||||
225
tools/gen_fps_arms.py
Normal 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
@ -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()
|
||||