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
|
# Canonical copies: ~/Documents/Destroyulater/3D-STORE (M3) and
|
||||||
# johnking@ultra:~/Documents/3D-STORE (origin).
|
# johnking@ultra:~/Documents/3D-STORE (origin).
|
||||||
3D-STORE/
|
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
|
Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics.
|
||||||
things on the M3 *before* committing: the destruction feels good, and the frame rate holds.
|
|
||||||
|
|
||||||
## Run it
|
## 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:**
|
**CLI:**
|
||||||
```sh
|
```sh
|
||||||
@ -16,34 +15,95 @@ things on the M3 *before* committing: the destruction feels good, and the frame
|
|||||||
|
|
||||||
| Input | Does |
|
| Input | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Left-click** | Swing the (nerf) hammer at whatever's under the cursor |
|
| **WASD / Space** | move / jump |
|
||||||
| **S** | Rain **500** rigid bodies — the **Day-0 stress gate**. Watch the FPS counter hold. |
|
| **mouse** | look (Esc releases the cursor, click recaptures) |
|
||||||
| **R** | Reset the rack |
|
| **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).
|
Tuned in one place: `scripts/Weapon.gd` (`vs` tables) and `scripts/Smashable.gd`
|
||||||
- Smash a **shelf** → it bursts into shards and vanishes → the **crates** on it fall →
|
(`PROFILES[kind].hp`). The office printer is the level boss — steel at `toughness_scale
|
||||||
the **cardboard jackets** spill → the **vinyl records** tumble out →
|
= 5.0`, so it's a real fight with anything but the sledge.
|
||||||
- **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.
|
|
||||||
|
|
||||||
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
|
`scripts/ViewModel.gd`. Real rigged hands (mixamorig, all 20 finger bones per hand)
|
||||||
where **material identity** is authored (color · shard count · brittleness · bounce).
|
holding real weapon meshes.
|
||||||
Swap `wood/cardboard/vinyl/glass/steel` behaviours here.
|
|
||||||
- `scripts/Main.gd` — builds the world + rack from primitives, hammer raycast, stress test, HUD.
|
|
||||||
|
|
||||||
## 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,
|
Motion is layered on one node: look-sway with spring-back, walk bob scaled by speed and
|
||||||
change `_piece()` in `Main.gd` to load a GLB instead of building a BoxMesh, e.g.
|
weapon heft, idle breathing, landing dip, and a keyframed swing arc per archetype
|
||||||
`load("res://assets/wooden-rack-type-long.glb").instantiate()` for the visual, keep the
|
(`overhead` / `horizontal` / `jab` / `thrust`).
|
||||||
convex collider. Your `3D-STORE/clean_glbs/*.glb` are ready to copy into `game/assets/`.
|
|
||||||
|
## 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:
|
func setup(cam: Camera3D) -> void:
|
||||||
_cam = cam
|
_cam = cam
|
||||||
process_mode = Node.PROCESS_MODE_ALWAYS # keep releasing hitstop even during a freeze
|
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)
|
_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).
|
## Called once per shatter (Main forwards Smashable.smashed here).
|
||||||
func impact(kind: String, pos: Vector3, shards: int) -> void:
|
func impact(kind: String, pos: Vector3, shards: int) -> void:
|
||||||
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
|
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
|
||||||
@ -134,6 +157,9 @@ func _synth(kind: String) -> PackedFloat32Array:
|
|||||||
"steel": dur = 0.55
|
"steel": dur = 0.55
|
||||||
"vinyl": dur = 0.22
|
"vinyl": dur = 0.22
|
||||||
"cardboard": dur = 0.18
|
"cardboard": dur = 0.18
|
||||||
|
"plastic": dur = 0.20
|
||||||
|
"paper": dur = 0.16
|
||||||
|
"clank": dur = 0.14
|
||||||
var n := int(rate * dur)
|
var n := int(rate * dur)
|
||||||
var out := PackedFloat32Array()
|
var out := PackedFloat32Array()
|
||||||
out.resize(n)
|
out.resize(n)
|
||||||
@ -155,6 +181,17 @@ func _synth(kind: String) -> PackedFloat32Array:
|
|||||||
"steel":
|
"steel":
|
||||||
env = exp(-t * 5.0)
|
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)
|
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)
|
_: # wood (and default)
|
||||||
env = exp(-t * 20.0)
|
env = exp(-t * 20.0)
|
||||||
s = (randf() * 2.0 - 1.0) * 0.7 * env + sin(TAU * 140.0 * t) * 0.4 * env
|
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
|
@onready var _cam: Camera3D
|
||||||
var _world: Node3D # everything smashable/debris lives here so reset is one line
|
var _world: Node3D # everything smashable/debris lives here so reset is one line
|
||||||
var _hud: Label
|
var _hud: Hud
|
||||||
var _smash_count := 0
|
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 _juice: Juice
|
||||||
var _player: Player # kept so GameMode can reach player.grab
|
var _player: Player # kept so GameMode can reach player.grab
|
||||||
var _records: Array[Record] = [] # the live records, handed to GameMode each round
|
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 printer := _glb_piece(PRINTER_GLB, "steel", o + Vector2(2.7, -0.3), false, desk["top"])
|
||||||
var boss := printer["piece"] as Smashable
|
var boss := printer["piece"] as Smashable
|
||||||
boss.is_boss = true
|
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
|
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"])
|
(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
|
_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()
|
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.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()
|
var disc := rec.disc_body()
|
||||||
if disc != null:
|
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)
|
_records.append(rec)
|
||||||
return 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()
|
col.position = ab.get_center()
|
||||||
s.add_child(col)
|
s.add_child(col)
|
||||||
s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on
|
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}
|
return {"piece": s, "top": sit_on + ab.size.y}
|
||||||
|
|
||||||
## Give a record's sleeve faces a random cover from the generated 3x3 sheets.
|
## 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)
|
s.add_child(col)
|
||||||
_world.add_child(s) # _ready() runs here (kind + frozen already set)
|
_world.add_child(s) # _ready() runs here (kind + frozen already set)
|
||||||
s.global_position = pos
|
s.global_position = pos
|
||||||
s.smashed.connect(_on_smashed)
|
_wire(s)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
|
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
|
||||||
_smash_count += 1
|
_smash_count += 1
|
||||||
|
_tally[kind] = int(_tally.get(kind, 0)) + 1
|
||||||
if _juice != null:
|
if _juice != null:
|
||||||
_juice.impact(kind, at, shards)
|
_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:
|
if _game_mode != null:
|
||||||
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
|
_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
|
# ---------------------------------------------------------------- input
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
# Melee (L-click punch / R-click kick), grab (E/G/drag), and Esc live in Player.gd.
|
# Swings, weapon slots (1-6 / wheel / Q), 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).
|
# 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 is InputEventKey and event.pressed and not event.echo:
|
||||||
if event.keycode == KEY_B:
|
match (event as InputEventKey).keycode:
|
||||||
_rain(500)
|
KEY_B:
|
||||||
elif event.keycode == KEY_R:
|
_rain(500)
|
||||||
_reset()
|
KEY_R:
|
||||||
elif event.keycode == KEY_1:
|
_reset()
|
||||||
_switch_mode(GameMode.Mode.FREE_PLAY)
|
KEY_M:
|
||||||
elif event.keycode == KEY_2:
|
_switch_mode(GameMode.Mode.FIND_MISFILED
|
||||||
_switch_mode(GameMode.Mode.FIND_MISFILED)
|
if _game_mode.mode == GameMode.Mode.FREE_PLAY
|
||||||
elif event.keycode == KEY_F and _game_mode != null:
|
else GameMode.Mode.FREE_PLAY)
|
||||||
_game_mode.report() # report the disc you're holding (FindMisfiled)
|
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.
|
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
|
||||||
func _rain(n: int) -> void:
|
func _rain(n: int) -> void:
|
||||||
@ -352,6 +389,8 @@ func _reset() -> void:
|
|||||||
for c in _world.get_children():
|
for c in _world.get_children():
|
||||||
c.queue_free()
|
c.queue_free()
|
||||||
_smash_count = 0
|
_smash_count = 0
|
||||||
|
_score = 0
|
||||||
|
_tally.clear()
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
_build_rack(Vector3.ZERO)
|
_build_rack(Vector3.ZERO)
|
||||||
# re-arm whatever mode we're in against the fresh set of records
|
# re-arm whatever mode we're in against the fresh set of records
|
||||||
@ -370,20 +409,35 @@ func _switch_mode(m: int) -> void:
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- HUD
|
# ---------------------------------------------------------------- HUD
|
||||||
func _setup_hud() -> void:
|
func _setup_hud() -> void:
|
||||||
var layer := CanvasLayer.new()
|
_hud = Hud.new()
|
||||||
add_child(layer)
|
add_child(_hud)
|
||||||
_hud = Label.new()
|
if _player != null:
|
||||||
_hud.position = Vector2(16, 12)
|
_player.weapon_changed.connect(_on_weapon_changed)
|
||||||
_hud.add_theme_font_size_override("font_size", 18)
|
|
||||||
_hud.add_theme_color_override("font_color", Color(1, 0.36, 0.62))
|
func _on_weapon_changed(w: Weapon) -> void:
|
||||||
layer.add_child(_hud)
|
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:
|
func _process(_dt: float) -> void:
|
||||||
if _hud == null:
|
if _hud == null:
|
||||||
return
|
return
|
||||||
var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size()
|
var bodies := get_tree().get_nodes_in_group("smashable").size() \
|
||||||
var c := _juice.combo() if _juice != null else 0
|
+ get_tree().get_nodes_in_group("debris").size()
|
||||||
var combo_str := (" COMBO x%d" % c) if c > 1 else ""
|
var w: Weapon = _player.weapon() if _player != null else null
|
||||||
var mode_line := _game_mode.hud_line() if _game_mode != null else ""
|
_hud.feed({
|
||||||
_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" % [
|
"fps": Engine.get_frames_per_second(),
|
||||||
Engine.get_frames_per_second(), bodies, _smash_count, combo_str, mode_line]
|
"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
|
extends CharacterBody3D
|
||||||
class_name Player
|
class_name Player
|
||||||
|
|
||||||
## First-person player for Destroyulator. Built entirely in code so Main.gd can
|
## First-person player for Destroyulator. Built entirely in code so Main.gd can spawn it
|
||||||
## spawn it where the old fixed Camera3D was and reuse `player.camera` for the
|
## where the old fixed Camera3D was and reuse `player.camera` for the crosshair raycast.
|
||||||
## crosshair raycast. Mouse-captured look, WASD move, Space jump; L-click PUNCH,
|
## Mouse-captured look, WASD move, Space jump; Esc releases the mouse (click recaptures).
|
||||||
## R-click KICK; Esc releases the mouse (click recaptures).
|
|
||||||
##
|
##
|
||||||
## Melee is a swappable MeleeAttack resource (reach/radius/impulse/knockback/cooldown)
|
## Melee is a `Weapon` resource (stats + the weapon-vs-material matrix + a swing
|
||||||
## so real weapons slot in by swapping the resource + viewmodel — movement never
|
## archetype) and the arms are a rigged `ViewModel`. Swapping weapon swaps both; movement
|
||||||
## changes. Hits are fired from screen-center (the mouse is captured, so its position
|
## never changes.
|
||||||
## is meaningless) and resolved in _physics_process, where direct_space_state is valid.
|
##
|
||||||
|
## 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
|
## 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.
|
## `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
|
@export var eye_height: float = 1.6
|
||||||
|
|
||||||
var camera: Camera3D # Main reads this for the crosshair raycast
|
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 _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 _pitch := 0.0
|
||||||
var _cd_left := 0.0
|
var _cd_left := 0.0
|
||||||
var _queued: MeleeAttack = null # buffered click, consumed next physics step
|
|
||||||
|
|
||||||
var _punch: MeleeAttack
|
# --- loadout -------------------------------------------------------------
|
||||||
var _kick: MeleeAttack
|
var _loadout: Array[Weapon] = []
|
||||||
|
var _slot := 0
|
||||||
|
var _prev_slot := 0
|
||||||
|
|
||||||
# resting local transforms of the viewmodel parts (relative to the camera)
|
# --- swing scheduling ----------------------------------------------------
|
||||||
const R_HAND_REST := Vector3(0.30, -0.32, -0.55)
|
var _queued := false # a click waiting for the cooldown
|
||||||
const L_HAND_REST := Vector3(-0.30, -0.34, -0.58)
|
var _swing_left := -1.0 # seconds until this swing's contact frame
|
||||||
const LEG_REST := Vector3(0.06, -1.05, -0.42)
|
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:
|
func _ready() -> void:
|
||||||
# --- collision capsule (feet at the body origin) ---
|
# --- collision capsule (feet at the body origin) ---
|
||||||
@ -64,10 +71,15 @@ func _ready() -> void:
|
|||||||
camera.name = "Camera3D"
|
camera.name = "Camera3D"
|
||||||
_head.add_child(camera)
|
_head.add_child(camera)
|
||||||
camera.current = true
|
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()
|
_build_crosshair()
|
||||||
_make_attacks()
|
|
||||||
|
|
||||||
# the record ritual hands: a child node that owns a hold point under the camera
|
# the record ritual hands: a child node that owns a hold point under the camera
|
||||||
grab = GrabController.new()
|
grab = GrabController.new()
|
||||||
@ -75,80 +87,39 @@ func _ready() -> void:
|
|||||||
grab.setup(camera)
|
grab.setup(camera)
|
||||||
|
|
||||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
weapon_changed.emit(_loadout[_slot])
|
||||||
|
|
||||||
# ---------------------------------------------------------------- attacks
|
# ---------------------------------------------------------------- loadout
|
||||||
func _make_attacks() -> void:
|
func weapon() -> Weapon:
|
||||||
_punch = MeleeAttack.new()
|
return _loadout[_slot]
|
||||||
_punch.reach = 1.7
|
|
||||||
_punch.radius = 0.34
|
|
||||||
_punch.impulse = 6.0
|
|
||||||
_punch.knockback = 2.5
|
|
||||||
_punch.cooldown = 0.26
|
|
||||||
|
|
||||||
_kick = MeleeAttack.new()
|
func loadout() -> Array[Weapon]:
|
||||||
_kick.reach = 2.2
|
return _loadout
|
||||||
_kick.radius = 0.42
|
|
||||||
_kick.impulse = 10.0
|
|
||||||
_kick.knockback = 5.0
|
|
||||||
_kick.cooldown = 0.48
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- viewmodel
|
func slot() -> int:
|
||||||
func _build_viewmodel() -> void:
|
return _slot
|
||||||
_hands_rig = Node3D.new()
|
|
||||||
_hands_rig.name = "ViewModel"
|
|
||||||
camera.add_child(_hands_rig)
|
|
||||||
|
|
||||||
var mat := StandardMaterial3D.new()
|
func select(i: int) -> void:
|
||||||
# semi-opaque: BOTH the alpha mode AND alpha<1 are required (alpha alone renders opaque)
|
if i < 0 or i >= _loadout.size() or i == _slot:
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
return
|
||||||
mat.albedo_color = Color(0.86, 0.62, 0.5, 0.55)
|
if viewmodel != null and viewmodel.is_swapping():
|
||||||
mat.no_depth_test = true # hands draw over world geo, never clip
|
return
|
||||||
mat.render_priority = 1
|
_prev_slot = _slot
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
_slot = i
|
||||||
mat.roughness = 0.85
|
_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)
|
func cycle(step: int) -> void:
|
||||||
_l_hand = _make_capsule(mat)
|
select(posmod(_slot + step, _loadout.size()))
|
||||||
_leg = _make_leg(mat)
|
|
||||||
_hands_rig.add_child(_r_hand)
|
|
||||||
_hands_rig.add_child(_l_hand)
|
|
||||||
_hands_rig.add_child(_leg)
|
|
||||||
|
|
||||||
_r_hand.position = R_HAND_REST
|
func quick_swap() -> void:
|
||||||
_l_hand.position = L_HAND_REST
|
select(_prev_slot)
|
||||||
_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
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- crosshair
|
||||||
func _build_crosshair() -> void:
|
func _build_crosshair() -> void:
|
||||||
var layer := CanvasLayer.new()
|
var layer := CanvasLayer.new()
|
||||||
layer.name = "CrosshairLayer"
|
layer.name = "CrosshairLayer"
|
||||||
@ -168,62 +139,87 @@ func _build_crosshair() -> void:
|
|||||||
# ---------------------------------------------------------------- input
|
# ---------------------------------------------------------------- input
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
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);
|
# while pulling a disc out, horizontal drag slides the disc (yaw is suspended);
|
||||||
# vertical still looks up/down so you're never fully locked.
|
# vertical still looks up/down so you're never fully locked.
|
||||||
if grab != null and grab.is_extracting():
|
if grab != null and grab.is_extracting():
|
||||||
grab.feed_drag(event.relative.x)
|
grab.feed_drag(mm.relative.x)
|
||||||
else:
|
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 = 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))
|
deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg))
|
||||||
_head.rotation.x = _pitch # pitch on the head only
|
_head.rotation.x = _pitch # pitch on the head only
|
||||||
return
|
return
|
||||||
|
|
||||||
if event is InputEventMouseButton and event.pressed:
|
if event is InputEventMouseButton and event.pressed:
|
||||||
|
var mb := event as InputEventMouseButton
|
||||||
if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
|
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
|
return
|
||||||
if event.button_index == MOUSE_BUTTON_LEFT:
|
match mb.button_index:
|
||||||
# holding a record? LMB throws the disc / trashes the sleeve, not a punch.
|
MOUSE_BUTTON_LEFT:
|
||||||
if grab != null and grab.is_holding():
|
# holding a record? LMB throws the disc / trashes the sleeve, not a swing.
|
||||||
grab.primary()
|
if grab != null and grab.is_holding():
|
||||||
else:
|
grab.primary()
|
||||||
_queue_attack(_punch)
|
else:
|
||||||
elif event.button_index == MOUSE_BUTTON_RIGHT:
|
_queued = true
|
||||||
_queue_attack(_kick)
|
MOUSE_BUTTON_WHEEL_UP:
|
||||||
|
cycle(-1)
|
||||||
|
MOUSE_BUTTON_WHEEL_DOWN:
|
||||||
|
cycle(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
if event is InputEventKey and event.pressed and not event.echo:
|
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
|
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
|
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
|
grab.drop() # drop / trash whatever's in hand
|
||||||
|
elif k == KEY_Q:
|
||||||
func _queue_attack(atk: MeleeAttack) -> void:
|
quick_swap()
|
||||||
# buffer the click; the hit resolves in _physics_process where the space state is valid.
|
elif k >= KEY_1 and k <= KEY_6:
|
||||||
if _cd_left <= 0.0:
|
select(k - KEY_1)
|
||||||
_queued = atk
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- physics / movement
|
# ---------------------------------------------------------------- physics / movement
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_cd_left = max(0.0, _cd_left - delta)
|
_cd_left = max(0.0, _cd_left - delta)
|
||||||
|
|
||||||
# resolve a buffered melee swing
|
# --- start a swing when the cooldown allows ---
|
||||||
if _queued != null and _cd_left <= 0.0:
|
if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()):
|
||||||
var atk := _queued
|
_queued = false
|
||||||
_queued = null
|
var w := weapon()
|
||||||
_cd_left = atk.cooldown
|
_cd_left = w.cooldown
|
||||||
var origin := camera.global_position
|
_swinging = w
|
||||||
var dir := -camera.global_transform.basis.z # camera forward = crosshair
|
_swing_left = w.swing_time * w.contact
|
||||||
atk.strike(self, origin, dir, [get_rid()])
|
if viewmodel != null:
|
||||||
_swing_viewmodel(atk == _kick)
|
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)
|
# gravity (only off the floor, or it accumulates and you rocket downward)
|
||||||
if not is_on_floor():
|
if not is_on_floor():
|
||||||
velocity += get_gravity() * delta
|
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():
|
if Input.is_key_pressed(KEY_SPACE) and is_on_floor():
|
||||||
velocity.y = jump_velocity
|
velocity.y = jump_velocity
|
||||||
@ -243,23 +239,57 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cosmetic swing
|
func _process(dt: float) -> void:
|
||||||
func _swing_viewmodel(is_kick: bool) -> void:
|
if viewmodel != null:
|
||||||
if _swing_tween and _swing_tween.is_valid():
|
var planar := Vector2(velocity.x, velocity.z).length()
|
||||||
_swing_tween.kill() # interrupt any lingering swing so rapid clicks don't fight
|
viewmodel.drive(_look_delta, planar, is_on_floor(), dt)
|
||||||
if is_kick:
|
_look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0))
|
||||||
_leg.position = LEG_REST
|
|
||||||
var up := LEG_REST + Vector3(0.0, 0.66, -0.12)
|
# ---------------------------------------------------------------- the hit
|
||||||
_swing_tween = create_tween()
|
## Forgiving SPHERE query from the camera centre (the crosshair), so melee doesn't need
|
||||||
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider.
|
||||||
_swing_tween.tween_property(_leg, "position", up, 0.10)
|
## Runs from _physics_process, where direct_space_state is valid.
|
||||||
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
func _strike(w: Weapon) -> Node:
|
||||||
_swing_tween.tween_property(_leg, "position", LEG_REST, 0.20)
|
var space := get_world_3d().direct_space_state
|
||||||
else:
|
if space == null:
|
||||||
_r_hand.position = R_HAND_REST
|
return null
|
||||||
var fwd := R_HAND_REST + Vector3(-0.06, 0.10, -0.34)
|
var origin := camera.global_position
|
||||||
_swing_tween = create_tween()
|
var dir := -camera.global_transform.basis.z
|
||||||
_swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
|
|
||||||
_swing_tween.tween_property(_r_hand, "position", fwd, 0.08)
|
var sphere := SphereShape3D.new()
|
||||||
_swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
sphere.radius = w.radius
|
||||||
_swing_tween.tween_property(_r_hand, "position", R_HAND_REST, 0.16)
|
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
|
## 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.
|
## 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
|
@export var start_frozen: bool = false
|
||||||
|
|
||||||
## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are
|
## 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
|
## 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.
|
## auto-assigns it from a `<name>.fractured.glb` next to the intact GLB.
|
||||||
@export var fractured_scene: PackedScene = null
|
@export var fractured_scene: PackedScene = null
|
||||||
## Hits to destroy. 1 = normal (one smash breaks it). The printer boss takes several.
|
## Scales this prop's hit points above its material default. The printer boss is 5.0.
|
||||||
@export var hits_to_break: int = 1
|
@export var toughness_scale: float = 1.0
|
||||||
## Boss props get a bigger death burst (more juice particles) + survive extra hits.
|
## Boss props get a bigger death burst (more juice particles).
|
||||||
@export var is_boss: bool = false
|
@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.
|
# 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 := {
|
const PROFILES := {
|
||||||
"wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05},
|
"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},
|
"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},
|
"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},
|
"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},
|
"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)
|
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 _shattered := false
|
||||||
var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves
|
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:
|
func _ready() -> void:
|
||||||
add_to_group("smashable")
|
add_to_group("smashable")
|
||||||
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
|
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 = PhysicsMaterial.new()
|
||||||
physics_material_override.bounce = p["bounce"]
|
physics_material_override.bounce = p["bounce"]
|
||||||
if start_frozen:
|
if start_frozen:
|
||||||
@ -68,18 +80,38 @@ func _on_body_entered(_body: Node) -> void:
|
|||||||
if _speed >= float(p["brittle_speed"]):
|
if _speed >= float(p["brittle_speed"]):
|
||||||
shatter(Vector3.ZERO)
|
shatter(Vector3.ZERO)
|
||||||
|
|
||||||
## Called by the hammer. Bosses (hits_to_break > 1) soak hits and visibly rock before
|
## Take a swing. `damage` is what the weapon deals to THIS material (see Weapon.vs);
|
||||||
## the final blow shatters them; a normal prop (hits_to_break == 1) breaks on hit one.
|
## the default of -1 means "whatever it takes", which keeps thrown-object and scripted
|
||||||
func smash(impulse: Vector3) -> void:
|
## 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:
|
if _shattered:
|
||||||
return
|
return
|
||||||
_hits_taken += 1
|
if damage < 0.0:
|
||||||
if _hits_taken < hits_to_break:
|
shatter(impulse)
|
||||||
# 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)
|
|
||||||
return
|
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:
|
func shatter(impulse: Vector3) -> void:
|
||||||
if _shattered:
|
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()
|
||||||