diff --git a/.gitignore b/.gitignore index d4fcddd..b9daeb2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Canonical copies: ~/Documents/Destroyulater/3D-STORE (M3) and # johnking@ultra:~/Documents/3D-STORE (origin). 3D-STORE/ + +# Blender preview renders (build artefacts from tools/gen_*.py) +tools/previews/ diff --git a/game/README.md b/game/README.md index ddf9e7e..96b2fb4 100644 --- a/game/README.md +++ b/game/README.md @@ -1,11 +1,10 @@ -# Destroyulator — Cascade Prototype +# Destroyulator — the game -Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. Built to prove two -things on the M3 *before* committing: the destruction feels good, and the frame rate holds. +Mac-first (Apple Silicon / Metal), **Godot 4.7**, **Jolt** physics. ## Run it -**Editor (easiest):** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶. +**Editor:** open `/Applications/Godot.app`, import this folder (`game/`), press **F5** / ▶. **CLI:** ```sh @@ -16,34 +15,95 @@ things on the M3 *before* committing: the destruction feels good, and the frame | Input | Does | |---|---| -| **Left-click** | Swing the (nerf) hammer at whatever's under the cursor | -| **S** | Rain **500** rigid bodies — the **Day-0 stress gate**. Watch the FPS counter hold. | -| **R** | Reset the rack | +| **WASD / Space** | move / jump | +| **mouse** | look (Esc releases the cursor, click recaptures) | +| **LMB** | swing the equipped weapon | +| **1–6 · wheel · Q** | pick a weapon · cycle · swap to previous | +| **E · drag ←→ · LMB · G** | grab a record · slide the disc out · throw it · drop | +| **H** | cycle HUD style (Arcade / Minimal / Work Order / Dev) | +| **M** | toggle game mode (FreePlay ⟷ Find-the-Misfiled-Disc) | +| **F** | report the disc in hand (Find-the-Misfiled-Disc) | +| **B · R** | rain 500 bodies (stress gate) · reset the store | -Top-left HUD shows **FPS · live body count · things smashed**. +## The weapon-vs-material matrix -## What it's proving — the cascade +This is the decision the game is built on. A swing deals `Weapon.power × Weapon.vs[material]` +against that material's `hp`, and there are three outcomes: it **breaks**, it **dents** +(knockback + a chip of damage), or it's **futile** — a dead clank, no score, no combo, +and a HUD nudge to switch tools. -The rack is nested the same way your store's DB is (`rack → shelf → crate → jacket → record`): +| | cardboard | paper | vinyl | glass | wood | steel | +|---|---|---|---|---|---|---| +| **Bare Hands** | ok | good | ok | ok | poor | useless | +| **Box Cutter** | **shreds** | **shreds** | good | poor | useless | useless | +| **Cricket Bat** | good | poor | **great** | **great** | ok | poor | +| **Crowbar** | ok | poor | ok | good | good | **great** | +| **Sledgehammer** | ok | poor | ok | **great** | **great** | **great** | +| **Fire Extinguisher** | ok | poor | ok | **great** | good | good | -- **Frame + shelves** start *frozen* (act static, hold everything up). -- Smash a **shelf** → it bursts into shards and vanishes → the **crates** on it fall → - the **cardboard jackets** spill → the **vinyl records** tumble out → -- **records are brittle** (`brittle_speed` in `Smashable.gd`) → they **crack a beat later - when they smack the floor**. A record that lands flat *survives* — there's your comedy/score beat. +Tuned in one place: `scripts/Weapon.gd` (`vs` tables) and `scripts/Smashable.gd` +(`PROFILES[kind].hp`). The office printer is the level boss — steel at `toughness_scale += 5.0`, so it's a real fight with anything but the sledge. -No scripted "release" logic — gravity reads the hierarchy. That's the whole point. +Weapons also differ in **when** they connect, not just how hard: the hit lands at +`Weapon.contact` through the swing animation, so the sledgehammer commits late and heavy +while the box cutter is near-instant. -## Files +## The viewmodel -- `scripts/Smashable.gd` — one component for every breakable piece. The `PROFILES` table is - where **material identity** is authored (color · shard count · brittleness · bounce). - Swap `wood/cardboard/vinyl/glass/steel` behaviours here. -- `scripts/Main.gd` — builds the world + rack from primitives, hammer raycast, stress test, HUD. +`scripts/ViewModel.gd`. Real rigged hands (mixamorig, all 20 finger bones per hand) +holding real weapon meshes. -## Next step: real art (one function) +The arms rig is **instanced once per hand** and each instance is *placed* so its own hand +bone lands on the grip — no IK solver. That works because in first person you only ever +see forearm and hand. Placement solves two aims at once: the fist's bore lines up with +the weapon shaft, and the forearm runs back toward where that shoulder would be. Both +directions are **measured off the rig** at load (pinky-knuckle → index-knuckle for the +bore, elbow → hand for the forearm), so the grip survives re-tuning `grip_rest_rot` +instead of needing a fresh set of hand-authored euler angles. -Everything is grey primitives so it runs with zero import deps. To use your actual assets, -change `_piece()` in `Main.gd` to load a GLB instead of building a BoxMesh, e.g. -`load("res://assets/wooden-rack-type-long.glb").instantiate()` for the visual, keep the -convex collider. Your `3D-STORE/clean_glbs/*.glb` are ready to copy into `game/assets/`. +Motion is layered on one node: look-sway with spring-back, walk bob scaled by speed and +weapon heft, idle breathing, landing dip, and a keyframed swing arc per archetype +(`overhead` / `horizontal` / `jab` / `thrust`). + +## Assets + +| Path | Source | +|---|---| +| `assets/viewmodel/fps_arms.glb` | `tools/gen_fps_arms.py` — cut from the GODVERSE modular character kit | +| `assets/viewmodel/{bat,sledge,crowbar,cutter,extinguisher}.glb` | `tools/gen_viewmodel.py` — procedural, grip at origin, shaft +Y | +| `assets/store/*.glb` + `*.fractured.glb` | Lane 2 prop batch; the fractured sibling drives real chunk destruction | +| `assets/art/sleeves_*.jpg` | 3×3 sheets of generated cover art, windowed per record via uv1 offset | + +Regenerate either set with: +```sh +/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/gen_viewmodel.py -- --render +``` + +## Dev harness + +`dev/` is not shipped. Because macOS screen-recording permission isn't available to the +CLI, the game records **itself**: + +```sh +Godot --path game --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 2250 dev/demo.tscn +``` + +`dev/DemoDriver.gd` drives the player through a scripted tour (every weapon, the material +matrix, the boss, all four HUDs) so a change can be eyeballed frame by frame without a +human at the keyboard. `dev/probe_*.gd` print rig/scale/placement numbers — renders are +too coarse to tell a 5 cm error from a 50 cm one. + +## Smoke test before committing + +```sh +/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300 +``` + +Zero script errors required. After adding any `class_name` script, run +`Godot --path game --editor --quit` first — headless Godot won't rescan a stale class cache. + +## What's still missing + +The store has no **room**: props sit on a grey plane in a black void, and several topple +on spawn before you touch them. That's the biggest remaining gap — see the top-level README. diff --git a/game/assets/viewmodel/bat.glb b/game/assets/viewmodel/bat.glb new file mode 100644 index 0000000..9fd0e30 Binary files /dev/null and b/game/assets/viewmodel/bat.glb differ diff --git a/game/assets/viewmodel/bat.glb.import b/game/assets/viewmodel/bat.glb.import new file mode 100644 index 0000000..3b5bcdb --- /dev/null +++ b/game/assets/viewmodel/bat.glb.import @@ -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 diff --git a/game/assets/viewmodel/crowbar.glb b/game/assets/viewmodel/crowbar.glb new file mode 100644 index 0000000..61faa52 Binary files /dev/null and b/game/assets/viewmodel/crowbar.glb differ diff --git a/game/assets/viewmodel/crowbar.glb.import b/game/assets/viewmodel/crowbar.glb.import new file mode 100644 index 0000000..070e2f5 --- /dev/null +++ b/game/assets/viewmodel/crowbar.glb.import @@ -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 diff --git a/game/assets/viewmodel/cutter.glb b/game/assets/viewmodel/cutter.glb new file mode 100644 index 0000000..fb6c5b5 Binary files /dev/null and b/game/assets/viewmodel/cutter.glb differ diff --git a/game/assets/viewmodel/cutter.glb.import b/game/assets/viewmodel/cutter.glb.import new file mode 100644 index 0000000..2948828 --- /dev/null +++ b/game/assets/viewmodel/cutter.glb.import @@ -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 diff --git a/game/assets/viewmodel/extinguisher.glb b/game/assets/viewmodel/extinguisher.glb new file mode 100644 index 0000000..b67ecf4 Binary files /dev/null and b/game/assets/viewmodel/extinguisher.glb differ diff --git a/game/assets/viewmodel/extinguisher.glb.import b/game/assets/viewmodel/extinguisher.glb.import new file mode 100644 index 0000000..2b7fca2 --- /dev/null +++ b/game/assets/viewmodel/extinguisher.glb.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms.glb b/game/assets/viewmodel/fps_arms.glb new file mode 100644 index 0000000..9b28fe9 Binary files /dev/null and b/game/assets/viewmodel/fps_arms.glb differ diff --git a/game/assets/viewmodel/fps_arms.glb.import b/game/assets/viewmodel/fps_arms.glb.import new file mode 100644 index 0000000..c5e7199 --- /dev/null +++ b/game/assets/viewmodel/fps_arms.glb.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png b/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png new file mode 100644 index 0000000..8717ac6 Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png differ diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png.import b/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png.import new file mode 100644 index 0000000..827267b --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Ch01_1001_Diffuse.png.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg b/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg new file mode 100644 index 0000000..22da8cb Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg.import b/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg.import new file mode 100644 index 0000000..c6c50ce --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Ch01_1001_Glossiness.jpg.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg b/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg new file mode 100644 index 0000000..1f430f9 Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg.import b/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg.import new file mode 100644 index 0000000..f3e1815 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Ch01_1001_Normal.jpg.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg b/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg new file mode 100644 index 0000000..4bf2d59 Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg.import b/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg.import new file mode 100644 index 0000000..5b1acc9 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Ch01_1001_Specular.jpg.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png b/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png new file mode 100644 index 0000000..e43795e Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png differ diff --git a/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png.import b/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png.import new file mode 100644 index 0000000..cdf3611 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Kachujin_diffuse.png.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg b/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg new file mode 100644 index 0000000..5d45184 Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg.import b/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg.import new file mode 100644 index 0000000..357d554 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Kachujin_diffuse_body.jpg.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg b/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg new file mode 100644 index 0000000..f8017f0 Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg.import b/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg.import new file mode 100644 index 0000000..b8e13f9 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Kachujin_normal.jpg.import @@ -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 diff --git a/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg b/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg new file mode 100644 index 0000000..11c671a Binary files /dev/null and b/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg differ diff --git a/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg.import b/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg.import new file mode 100644 index 0000000..9d54d25 --- /dev/null +++ b/game/assets/viewmodel/fps_arms_Kachujin_specular.jpg.import @@ -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 diff --git a/game/assets/viewmodel/sledge.glb b/game/assets/viewmodel/sledge.glb new file mode 100644 index 0000000..a0e7ee4 Binary files /dev/null and b/game/assets/viewmodel/sledge.glb differ diff --git a/game/assets/viewmodel/sledge.glb.import b/game/assets/viewmodel/sledge.glb.import new file mode 100644 index 0000000..c741e6b --- /dev/null +++ b/game/assets/viewmodel/sledge.glb.import @@ -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 diff --git a/game/dev/DemoDriver.gd b/game/dev/DemoDriver.gd new file mode 100644 index 0000000..1c73258 --- /dev/null +++ b/game/dev/DemoDriver.gd @@ -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() diff --git a/game/dev/DemoDriver.gd.uid b/game/dev/DemoDriver.gd.uid new file mode 100644 index 0000000..447753a --- /dev/null +++ b/game/dev/DemoDriver.gd.uid @@ -0,0 +1 @@ +uid://lveiga5ufogp diff --git a/game/dev/demo.tscn b/game/dev/demo.tscn new file mode 100644 index 0000000..08517a8 --- /dev/null +++ b/game/dev/demo.tscn @@ -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") diff --git a/game/dev/probe_arms.gd b/game/dev/probe_arms.gd new file mode 100644 index 0000000..81766bd --- /dev/null +++ b/game/dev/probe_arms.gd @@ -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 diff --git a/game/dev/probe_arms.gd.uid b/game/dev/probe_arms.gd.uid new file mode 100644 index 0000000..18a30f0 --- /dev/null +++ b/game/dev/probe_arms.gd.uid @@ -0,0 +1 @@ +uid://bjtt6khwqooov diff --git a/game/dev/probe_scale.gd b/game/dev/probe_scale.gd new file mode 100644 index 0000000..5f0a297 --- /dev/null +++ b/game/dev/probe_scale.gd @@ -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 diff --git a/game/dev/probe_scale.gd.uid b/game/dev/probe_scale.gd.uid new file mode 100644 index 0000000..021b6e2 --- /dev/null +++ b/game/dev/probe_scale.gd.uid @@ -0,0 +1 @@ +uid://de3cnb8nvrojh diff --git a/game/dev/probe_vm.gd b/game/dev/probe_vm.gd new file mode 100644 index 0000000..c380b77 --- /dev/null +++ b/game/dev/probe_vm.gd @@ -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 diff --git a/game/dev/probe_vm.gd.uid b/game/dev/probe_vm.gd.uid new file mode 100644 index 0000000..a2a8b64 --- /dev/null +++ b/game/dev/probe_vm.gd.uid @@ -0,0 +1 @@ +uid://cd2it4cwus0xw diff --git a/game/scripts/Hud.gd b/game/scripts/Hud.gd new file mode 100644 index 0000000..0c4d4b0 --- /dev/null +++ b/game/scripts/Hud.gd @@ -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 diff --git a/game/scripts/Hud.gd.uid b/game/scripts/Hud.gd.uid new file mode 100644 index 0000000..de58452 --- /dev/null +++ b/game/scripts/Hud.gd.uid @@ -0,0 +1 @@ +uid://bovbusa0dmqe8 diff --git a/game/scripts/Juice.gd b/game/scripts/Juice.gd index a9307f3..22409e5 100644 --- a/game/scripts/Juice.gd +++ b/game/scripts/Juice.gd @@ -27,9 +27,32 @@ var _sounds: Dictionary = {} # kind -> AudioStreamWAV func setup(cam: Camera3D) -> void: _cam = cam process_mode = Node.PROCESS_MODE_ALWAYS # keep releasing hitstop even during a freeze - for k in ["wood", "cardboard", "vinyl", "glass", "steel"]: + for k in ["wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper", "clank"]: _sounds[k] = _make_wav(_synth(k), 22050) +## A hit that couldn't hurt the material — box cutter on a filing cabinet. Deliberately +## unsatisfying: a dead clank, a token wobble, no particles, and NO combo credit. It has +## to read as "wrong tool" without a tutorial popup. +func resist(_kind: String, pos: Vector3) -> void: + _play_sound("clank", pos) + add_trauma(0.09) + +## A hit that hurt but didn't kill. The material's own voice, a small spray of chips, +## and a shorter hitstop than a kill so heavy props feel like work, not like a stall. +func dent(kind: String, pos: Vector3, _frac: float) -> void: + var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"] + _burst(color, pos, 5) + _play_sound(kind, pos) + add_trauma(0.16) + _hitstop(0.025) + +## 1.0 just after a smash, falling to 0.0 as the combo window closes. Drives the HUD bar. +func combo_decay() -> float: + if _combo <= 1: + return 0.0 + var left := float(combo_window_ms) - float(Time.get_ticks_msec() - _last_smash_ms) + return clampf(left / float(combo_window_ms), 0.0, 1.0) + ## Called once per shatter (Main forwards Smashable.smashed here). func impact(kind: String, pos: Vector3, shards: int) -> void: var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"] @@ -134,6 +157,9 @@ func _synth(kind: String) -> PackedFloat32Array: "steel": dur = 0.55 "vinyl": dur = 0.22 "cardboard": dur = 0.18 + "plastic": dur = 0.20 + "paper": dur = 0.16 + "clank": dur = 0.14 var n := int(rate * dur) var out := PackedFloat32Array() out.resize(n) @@ -155,6 +181,17 @@ func _synth(kind: String) -> PackedFloat32Array: "steel": env = exp(-t * 5.0) s = sin(TAU * 190.0 * t) * 0.5 * env + sin(TAU * 285.0 * t) * 0.25 * env + sin(TAU * 470.0 * t) * 0.12 * env + (randf() * 2.0 - 1.0) * 0.2 * exp(-t * 40.0) + "plastic": + env = exp(-t * 24.0) + s = sin(TAU * 780.0 * t) * 0.35 * env + (randf() * 2.0 - 1.0) * 0.4 * env + "paper": + env = exp(-t * 30.0) + s = (randf() * 2.0 - 1.0) * 0.32 * env * (0.5 + 0.5 * sin(TAU * 40.0 * t)) + "clank": + # short, dull, no sustain — the sound of a tool bouncing off, and of + # a decision to swap weapon. + env = exp(-t * 46.0) + s = sin(TAU * 240.0 * t) * 0.34 * env + (randf() * 2.0 - 1.0) * 0.26 * exp(-t * 90.0) _: # wood (and default) env = exp(-t * 20.0) s = (randf() * 2.0 - 1.0) * 0.7 * env + sin(TAU * 140.0 * t) * 0.4 * env diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 6209a22..3aaf6c1 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -14,8 +14,17 @@ extends Node3D @onready var _cam: Camera3D var _world: Node3D # everything smashable/debris lives here so reset is one line -var _hud: Label +var _hud: Hud var _smash_count := 0 +var _score := 0 +var _tally := {} # material -> how many of it you've destroyed (work-order HUD) + +## Score per material. Steel is worth the most because it's the hardest to break, which +## is what makes the sledgehammer worth its cooldown. +const POINTS := { + "paper": 5, "cardboard": 10, "plastic": 20, "vinyl": 25, + "wood": 30, "glass": 40, "steel": 60, +} var _juice: Juice var _player: Player # kept so GameMode can reach player.grab var _records: Array[Record] = [] # the live records, handed to GameMode each round @@ -145,7 +154,7 @@ func _build_office(origin: Vector3) -> void: var printer := _glb_piece(PRINTER_GLB, "steel", o + Vector2(2.7, -0.3), false, desk["top"]) var boss := printer["piece"] as Smashable boss.is_boss = true - boss.hits_to_break = 5 # the boss soaks a beating before it bursts + boss.toughness_scale = 5.0 # steel x5 — a real fight for anything but the sledge boss.mass = 8.0 # heavy, so hits rock it but don't shove it off the desk (desk["piece"] as Smashable).supports.append(printer["piece"]) @@ -170,10 +179,10 @@ func _place_record(xz: Vector2, sit_on: float) -> Record: _skin_record(rec.sleeve_visual) # random cover art on the jacket faces var ab := _body_local_aabb(rec, rec.sleeve_visual) if rec.sleeve_visual != null else AABB() rec.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) - rec.smashed.connect(_on_smashed) # the empty sleeve breaking counts + juices + _wire(rec) # the empty sleeve breaking counts + juices var disc := rec.disc_body() if disc != null: - disc.smashed.connect(_on_smashed) # the cracked disc counts + juices too + _wire(disc) # the cracked disc counts + juices too _records.append(rec) return rec @@ -204,7 +213,7 @@ func _glb_piece(path: String, kind: String, xz: Vector2, frozen: bool, sit_on := col.position = ab.get_center() s.add_child(col) s.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y) # AABB bottom lands on sit_on - s.smashed.connect(_on_smashed) + _wire(s) return {"piece": s, "top": sit_on + ab.size.y} ## Give a record's sleeve faces a random cover from the generated 3x3 sheets. @@ -304,31 +313,59 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := s.add_child(col) _world.add_child(s) # _ready() runs here (kind + frozen already set) s.global_position = pos - s.smashed.connect(_on_smashed) + _wire(s) return s func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 + _tally[kind] = int(_tally.get(kind, 0)) + 1 if _juice != null: _juice.impact(kind, at, shards) + # score AFTER impact() so this break's own combo step is counted + _score += int(POINTS.get(kind, 15)) * maxi(_juice.combo(), 1) + else: + _score += int(POINTS.get(kind, 15)) if _game_mode != null: _game_mode.on_smashed() # scores the mess during a FindMisfiled round +## A swing that couldn't hurt what it hit. No score, no combo — just the clank, plus a +## one-time nudge toward the right tool. +func _on_resisted(kind: String, at: Vector3) -> void: + if _juice != null: + _juice.resist(kind, at) + if _hud != null: + _hud.toast("%s shrugs it off — try a heavier tool" % kind.to_upper()) + +func _on_damaged(kind: String, at: Vector3, frac: float) -> void: + if _juice != null: + _juice.dent(kind, at, frac) + +## Every Smashable routes its three outcomes here. +func _wire(s: Smashable) -> void: + s.smashed.connect(_on_smashed) + s.resisted.connect(_on_resisted) + s.damaged.connect(_on_damaged) + # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: - # Melee (L-click punch / R-click kick), grab (E/G/drag), and Esc live in Player.gd. - # Here: debug keys + the game-mode keys. Rain is on B (S walks backward now). + # Swings, weapon slots (1-6 / wheel / Q), grab (E/G/drag) and Esc live in Player.gd. + # Number keys are the LOADOUT now, so game modes moved to M and the HUD cycles on H. if event is InputEventKey and event.pressed and not event.echo: - if event.keycode == KEY_B: - _rain(500) - elif event.keycode == KEY_R: - _reset() - elif event.keycode == KEY_1: - _switch_mode(GameMode.Mode.FREE_PLAY) - elif event.keycode == KEY_2: - _switch_mode(GameMode.Mode.FIND_MISFILED) - elif event.keycode == KEY_F and _game_mode != null: - _game_mode.report() # report the disc you're holding (FindMisfiled) + match (event as InputEventKey).keycode: + KEY_B: + _rain(500) + KEY_R: + _reset() + KEY_M: + _switch_mode(GameMode.Mode.FIND_MISFILED + if _game_mode.mode == GameMode.Mode.FREE_PLAY + else GameMode.Mode.FREE_PLAY) + KEY_H: + if _hud != null: + _hud.cycle() + KEY_F: + if _game_mode != null: + _game_mode.report() # report the disc you're holding (FindMisfiled) # The Day-0 stress gate: rain N bodies, watch the FPS counter hold. func _rain(n: int) -> void: @@ -352,6 +389,8 @@ func _reset() -> void: for c in _world.get_children(): c.queue_free() _smash_count = 0 + _score = 0 + _tally.clear() await get_tree().process_frame _build_rack(Vector3.ZERO) # re-arm whatever mode we're in against the fresh set of records @@ -370,20 +409,35 @@ func _switch_mode(m: int) -> void: # ---------------------------------------------------------------- HUD func _setup_hud() -> void: - var layer := CanvasLayer.new() - add_child(layer) - _hud = Label.new() - _hud.position = Vector2(16, 12) - _hud.add_theme_font_size_override("font_size", 18) - _hud.add_theme_color_override("font_color", Color(1, 0.36, 0.62)) - layer.add_child(_hud) + _hud = Hud.new() + add_child(_hud) + if _player != null: + _player.weapon_changed.connect(_on_weapon_changed) + +func _on_weapon_changed(w: Weapon) -> void: + if _hud != null and w != null: + _hud.toast(w.display_name) + +const KEYS_LINE := "WASD move · 1-6 / wheel weapon · Q swap · LMB swing · E grab · drag ←→ pull · G drop · F report · M mode · H hud · B rain · R reset" func _process(_dt: float) -> void: if _hud == null: return - var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size() - var c := _juice.combo() if _juice != null else 0 - var combo_str := (" COMBO x%d" % c) if c > 1 else "" - var mode_line := _game_mode.hud_line() if _game_mode != null else "" - _hud.text = "FPS %d bodies %d smashed %d%s\n%s\nWASD move E grab drag ←→ pull LMB throw/punch G drop F report B rain R reset" % [ - Engine.get_frames_per_second(), bodies, _smash_count, combo_str, mode_line] + var bodies := get_tree().get_nodes_in_group("smashable").size() \ + + get_tree().get_nodes_in_group("debris").size() + var w: Weapon = _player.weapon() if _player != null else null + _hud.feed({ + "fps": Engine.get_frames_per_second(), + "bodies": bodies, + "smashed": _smash_count, + "score": _score, + "tally": _tally, + "combo": _juice.combo() if _juice != null else 0, + "combo_decay": _juice.combo_decay() if _juice != null else 0.0, + "weapon": w.display_name if w != null else "-", + "weapon_blurb": w.blurb if w != null else "", + "weapon_stats": ("pow %.1f cd %.2fs reach %.1fm" % [w.power, w.cooldown, w.reach]) if w != null else "", + "slot": _player.slot() if _player != null else 0, + "mode_line": _game_mode.hud_line() if _game_mode != null else "", + "keys": KEYS_LINE, + }) diff --git a/game/scripts/MeleeAttack.gd b/game/scripts/MeleeAttack.gd deleted file mode 100644 index 3ef510d..0000000 --- a/game/scripts/MeleeAttack.gd +++ /dev/null @@ -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 diff --git a/game/scripts/MeleeAttack.gd.uid b/game/scripts/MeleeAttack.gd.uid deleted file mode 100644 index 646b0ee..0000000 --- a/game/scripts/MeleeAttack.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://d4anp68080714 diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 6aeca8f..a4377dc 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -1,15 +1,17 @@ extends CharacterBody3D class_name Player -## First-person player for Destroyulator. Built entirely in code so Main.gd can -## spawn it where the old fixed Camera3D was and reuse `player.camera` for the -## crosshair raycast. Mouse-captured look, WASD move, Space jump; L-click PUNCH, -## R-click KICK; Esc releases the mouse (click recaptures). +## First-person player for Destroyulator. Built entirely in code so Main.gd can spawn it +## where the old fixed Camera3D was and reuse `player.camera` for the crosshair raycast. +## Mouse-captured look, WASD move, Space jump; Esc releases the mouse (click recaptures). ## -## Melee is a swappable MeleeAttack resource (reach/radius/impulse/knockback/cooldown) -## so real weapons slot in by swapping the resource + viewmodel — movement never -## changes. Hits are fired from screen-center (the mouse is captured, so its position -## is meaningless) and resolved in _physics_process, where direct_space_state is valid. +## Melee is a `Weapon` resource (stats + the weapon-vs-material matrix + a swing +## archetype) and the arms are a rigged `ViewModel`. Swapping weapon swaps both; movement +## never changes. +## +## The hit does NOT land on the click — it lands at `weapon.contact` through the swing +## animation. That single delay is most of why a sledgehammer feels different from a box +## cutter: you commit, then it connects. ## ## Godot 4.7 GDScript 2.0. No 3.x APIs: move_and_slide() takes no args and reads/writes ## `velocity`; get_gravity() supplies the project default; mouse_mode is a property. @@ -23,26 +25,31 @@ class_name Player @export var eye_height: float = 1.6 var camera: Camera3D # Main reads this for the crosshair raycast -var grab: GrabController # the record grab/extract/throw hands (Main reads this) +var grab: GrabController # the record grab/extract/throw hands +var viewmodel: ViewModel # the arms + weapon rig var _head: Node3D -var _hands_rig: Node3D # cosmetic viewmodel (hands + leg), swung on attack -var _r_hand: MeshInstance3D -var _l_hand: MeshInstance3D -var _leg: MeshInstance3D -var _swing_tween: Tween - var _pitch := 0.0 var _cd_left := 0.0 -var _queued: MeleeAttack = null # buffered click, consumed next physics step -var _punch: MeleeAttack -var _kick: MeleeAttack +# --- loadout ------------------------------------------------------------- +var _loadout: Array[Weapon] = [] +var _slot := 0 +var _prev_slot := 0 -# resting local transforms of the viewmodel parts (relative to the camera) -const R_HAND_REST := Vector3(0.30, -0.32, -0.55) -const L_HAND_REST := Vector3(-0.30, -0.34, -0.58) -const LEG_REST := Vector3(0.06, -1.05, -0.42) +# --- swing scheduling ---------------------------------------------------- +var _queued := false # a click waiting for the cooldown +var _swing_left := -1.0 # seconds until this swing's contact frame +var _swinging: Weapon = null + +# --- feel bookkeeping ---------------------------------------------------- +var _look_delta := Vector2.ZERO # mouse motion this frame, fed to viewmodel sway +var _was_on_floor := true +var _fall_speed := 0.0 + +signal weapon_changed(w: Weapon) +signal hit_landed(node: Node, w: Weapon) +signal swung(w: Weapon) func _ready() -> void: # --- collision capsule (feet at the body origin) --- @@ -64,10 +71,15 @@ func _ready() -> void: camera.name = "Camera3D" _head.add_child(camera) camera.current = true + camera.near = 0.03 # the viewmodel sits ~40 cm out; don't clip it + + _loadout = Weapon.loadout() + + viewmodel = ViewModel.new() + viewmodel.setup(camera) # parents itself to the camera + viewmodel.equip(_loadout[_slot]) - _build_viewmodel() _build_crosshair() - _make_attacks() # the record ritual hands: a child node that owns a hold point under the camera grab = GrabController.new() @@ -75,80 +87,39 @@ func _ready() -> void: grab.setup(camera) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + weapon_changed.emit(_loadout[_slot]) -# ---------------------------------------------------------------- attacks -func _make_attacks() -> void: - _punch = MeleeAttack.new() - _punch.reach = 1.7 - _punch.radius = 0.34 - _punch.impulse = 6.0 - _punch.knockback = 2.5 - _punch.cooldown = 0.26 +# ---------------------------------------------------------------- loadout +func weapon() -> Weapon: + return _loadout[_slot] - _kick = MeleeAttack.new() - _kick.reach = 2.2 - _kick.radius = 0.42 - _kick.impulse = 10.0 - _kick.knockback = 5.0 - _kick.cooldown = 0.48 +func loadout() -> Array[Weapon]: + return _loadout -# ---------------------------------------------------------------- viewmodel -func _build_viewmodel() -> void: - _hands_rig = Node3D.new() - _hands_rig.name = "ViewModel" - camera.add_child(_hands_rig) +func slot() -> int: + return _slot - var mat := StandardMaterial3D.new() - # semi-opaque: BOTH the alpha mode AND alpha<1 are required (alpha alone renders opaque) - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = Color(0.86, 0.62, 0.5, 0.55) - mat.no_depth_test = true # hands draw over world geo, never clip - mat.render_priority = 1 - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.roughness = 0.85 +func select(i: int) -> void: + if i < 0 or i >= _loadout.size() or i == _slot: + return + if viewmodel != null and viewmodel.is_swapping(): + return + _prev_slot = _slot + _slot = i + _queued = false + _swing_left = -1.0 + _swinging = null + if viewmodel != null: + viewmodel.request_swap(_loadout[_slot]) + weapon_changed.emit(_loadout[_slot]) - _r_hand = _make_capsule(mat) - _l_hand = _make_capsule(mat) - _leg = _make_leg(mat) - _hands_rig.add_child(_r_hand) - _hands_rig.add_child(_l_hand) - _hands_rig.add_child(_leg) +func cycle(step: int) -> void: + select(posmod(_slot + step, _loadout.size())) - _r_hand.position = R_HAND_REST - _l_hand.position = L_HAND_REST - _leg.position = LEG_REST - _r_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(-12)) - _l_hand.rotation = Vector3(deg_to_rad(-70), 0.0, deg_to_rad(12)) - _leg.rotation = Vector3(deg_to_rad(-90), 0.0, 0.0) - -func _make_capsule(mat: StandardMaterial3D) -> MeshInstance3D: - var mi := MeshInstance3D.new() - var cap := CapsuleMesh.new() - cap.radius = 0.055 - cap.height = 0.20 # full height incl. the hemispherical caps - mi.mesh = cap - mi.material_override = mat - mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - return mi - -func _make_leg(mat: StandardMaterial3D) -> MeshInstance3D: - var shin := MeshInstance3D.new() - var cap := CapsuleMesh.new() - cap.radius = 0.075 - cap.height = 0.34 - shin.mesh = cap - shin.material_override = mat - shin.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - var foot := MeshInstance3D.new() - var box := BoxMesh.new() - box.size = Vector3(0.11, 0.07, 0.22) - foot.mesh = box - foot.material_override = mat - foot.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - foot.position = Vector3(0.0, -0.20, 0.09) - shin.add_child(foot) - return shin +func quick_swap() -> void: + select(_prev_slot) +# ---------------------------------------------------------------- crosshair func _build_crosshair() -> void: var layer := CanvasLayer.new() layer.name = "CrosshairLayer" @@ -168,62 +139,87 @@ func _build_crosshair() -> void: # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + var mm := event as InputEventMouseMotion + _look_delta += mm.relative * 0.01 # while pulling a disc out, horizontal drag slides the disc (yaw is suspended); # vertical still looks up/down so you're never fully locked. if grab != null and grab.is_extracting(): - grab.feed_drag(event.relative.x) + grab.feed_drag(mm.relative.x) else: - rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only + rotate_y(-mm.relative.x * mouse_sensitivity) # yaw on the body only _pitch = clamp( - _pitch - event.relative.y * mouse_sensitivity, + _pitch - mm.relative.y * mouse_sensitivity, deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg)) _head.rotation.x = _pitch # pitch on the head only return if event is InputEventMouseButton and event.pressed: + var mb := event as InputEventMouseButton if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; this one isn't an attack + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; not an attack return - if event.button_index == MOUSE_BUTTON_LEFT: - # holding a record? LMB throws the disc / trashes the sleeve, not a punch. - if grab != null and grab.is_holding(): - grab.primary() - else: - _queue_attack(_punch) - elif event.button_index == MOUSE_BUTTON_RIGHT: - _queue_attack(_kick) + match mb.button_index: + MOUSE_BUTTON_LEFT: + # holding a record? LMB throws the disc / trashes the sleeve, not a swing. + if grab != null and grab.is_holding(): + grab.primary() + else: + _queued = true + MOUSE_BUTTON_WHEEL_UP: + cycle(-1) + MOUSE_BUTTON_WHEEL_DOWN: + cycle(1) return if event is InputEventKey and event.pressed and not event.echo: - if event.keycode == KEY_ESCAPE: + var k := (event as InputEventKey).keycode + if k == KEY_ESCAPE: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE - elif event.keycode == KEY_E and grab != null: + elif k == KEY_E and grab != null: grab.try_grab() # grab the record under the crosshair - elif event.keycode == KEY_G and grab != null: + elif k == KEY_G and grab != null: grab.drop() # drop / trash whatever's in hand - -func _queue_attack(atk: MeleeAttack) -> void: - # buffer the click; the hit resolves in _physics_process where the space state is valid. - if _cd_left <= 0.0: - _queued = atk + elif k == KEY_Q: + quick_swap() + elif k >= KEY_1 and k <= KEY_6: + select(k - KEY_1) # ---------------------------------------------------------------- physics / movement func _physics_process(delta: float) -> void: _cd_left = max(0.0, _cd_left - delta) - # resolve a buffered melee swing - if _queued != null and _cd_left <= 0.0: - var atk := _queued - _queued = null - _cd_left = atk.cooldown - var origin := camera.global_position - var dir := -camera.global_transform.basis.z # camera forward = crosshair - atk.strike(self, origin, dir, [get_rid()]) - _swing_viewmodel(atk == _kick) + # --- start a swing when the cooldown allows --- + if _queued and _cd_left <= 0.0 and (viewmodel == null or not viewmodel.is_swapping()): + _queued = false + var w := weapon() + _cd_left = w.cooldown + _swinging = w + _swing_left = w.swing_time * w.contact + if viewmodel != null: + viewmodel.start_swing() + swung.emit(w) + + # --- land the hit at the contact frame, not on the click --- + if _swing_left >= 0.0: + _swing_left -= delta + if _swing_left <= 0.0: + var w := _swinging + _swing_left = -1.0 + _swinging = null + if w != null: + var hit := _strike(w) + if hit != null: + hit_landed.emit(hit, w) # gravity (only off the floor, or it accumulates and you rocket downward) if not is_on_floor(): velocity += get_gravity() * delta + _fall_speed = maxf(_fall_speed, -velocity.y) + elif not _was_on_floor: + if viewmodel != null: + viewmodel.land(_fall_speed) + _fall_speed = 0.0 + _was_on_floor = is_on_floor() if Input.is_key_pressed(KEY_SPACE) and is_on_floor(): velocity.y = jump_velocity @@ -243,23 +239,57 @@ func _physics_process(delta: float) -> void: move_and_slide() -# ---------------------------------------------------------------- cosmetic swing -func _swing_viewmodel(is_kick: bool) -> void: - if _swing_tween and _swing_tween.is_valid(): - _swing_tween.kill() # interrupt any lingering swing so rapid clicks don't fight - if is_kick: - _leg.position = LEG_REST - var up := LEG_REST + Vector3(0.0, 0.66, -0.12) - _swing_tween = create_tween() - _swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) - _swing_tween.tween_property(_leg, "position", up, 0.10) - _swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) - _swing_tween.tween_property(_leg, "position", LEG_REST, 0.20) - else: - _r_hand.position = R_HAND_REST - var fwd := R_HAND_REST + Vector3(-0.06, 0.10, -0.34) - _swing_tween = create_tween() - _swing_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) - _swing_tween.tween_property(_r_hand, "position", fwd, 0.08) - _swing_tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) - _swing_tween.tween_property(_r_hand, "position", R_HAND_REST, 0.16) +func _process(dt: float) -> void: + if viewmodel != null: + var planar := Vector2(velocity.x, velocity.z).length() + viewmodel.drive(_look_delta, planar, is_on_floor(), dt) + _look_delta = _look_delta.lerp(Vector2.ZERO, clampf(dt * 14.0, 0.0, 1.0)) + +# ---------------------------------------------------------------- the hit +## Forgiving SPHERE query from the camera centre (the crosshair), so melee doesn't need +## pixel aim. intersect_shape is NOT distance-sorted, so pick the nearest collider. +## Runs from _physics_process, where direct_space_state is valid. +func _strike(w: Weapon) -> Node: + var space := get_world_3d().direct_space_state + if space == null: + return null + var origin := camera.global_position + var dir := -camera.global_transform.basis.z + + var sphere := SphereShape3D.new() + sphere.radius = w.radius + var params := PhysicsShapeQueryParameters3D.new() + params.shape = sphere + params.transform = Transform3D(Basis.IDENTITY, origin + dir * w.reach) + params.collide_with_bodies = true + params.collide_with_areas = false + params.exclude = [get_rid()] + + var hits := space.intersect_shape(params, 8) + if hits.is_empty(): + return null + + var best: Node = null + var best_d := INF + for h in hits: + var c = h.get("collider") + if c == null or not (c is Node3D): + continue + var d: float = (c.global_position - origin).length_squared() + if d < best_d: + best_d = d + best = c + + if best == null: + return null + + # Smashable extends RigidBody3D, so this branch MUST come first or we'd only shove + # props around and never break them. + if best is Smashable: + var s := best as Smashable + s.smash(dir * w.impulse, w.damage_against(s.kind)) + elif best is RigidBody3D: + var hit_pos: Vector3 = origin + dir * w.reach + (best as RigidBody3D).apply_impulse(dir * w.knockback, + hit_pos - (best as RigidBody3D).global_position) + return best diff --git a/game/scripts/Smashable.gd b/game/scripts/Smashable.gd index a8adcf0..4e6818a 100644 --- a/game/scripts/Smashable.gd +++ b/game/scripts/Smashable.gd @@ -10,30 +10,40 @@ class_name Smashable ## hard enough — that's the satisfying TAIL of the cascade: the record survives the ## tumble out of its jacket, then cracks a beat later when it smacks the floor. -@export_enum("wood", "cardboard", "vinyl", "glass", "steel") var kind: String = "wood" +@export_enum("wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper") var kind: String = "wood" @export var start_frozen: bool = false ## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are ## the real shards. When set, shatter() spawns those instead of primitive cubes. Main ## auto-assigns it from a `.fractured.glb` next to the intact GLB. @export var fractured_scene: PackedScene = null -## Hits to destroy. 1 = normal (one smash breaks it). The printer boss takes several. -@export var hits_to_break: int = 1 -## Boss props get a bigger death burst (more juice particles) + survive extra hits. +## Scales this prop's hit points above its material default. The printer boss is 5.0. +@export var toughness_scale: float = 1.0 +## Boss props get a bigger death burst (more juice particles). @export var is_boss: bool = false -var _hits_taken := 0 +var _hp := 1.0 +var _hp_max := 1.0 # per-material behaviour — this table is where "material identity" is authored. -# brittle_speed 0.0 == not brittle (won't self-shatter on impact). +# hp ........... damage it soaks. A swing deals Weapon.power * Weapon.vs[kind], so +# this is the other half of the weapon-vs-material matrix. +# brittle_speed 0.0 == not brittle (won't self-shatter on impact). const PROFILES := { - "wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05}, - "cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0}, - "vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12}, - "glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0}, - "steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25}, + "wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05, "hp": 2.2}, + "cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.6}, + "vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12, "hp": 0.9}, + "glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0, "hp": 1.0}, + "steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25, "hp": 4.5}, + "plastic": {"color": Color(0.82, 0.78, 0.70), "shards": 6, "brittle_speed": 0.0, "bounce": 0.15, "hp": 1.4}, + "paper": {"color": Color(0.92, 0.90, 0.84), "shards": 3, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.3}, } signal smashed(kind: String, at: Vector3, shard_count: int) +## A hit landed but this weapon can't meaningfully hurt this material — the +## box-cutter-on-a-filing-cabinet clank. Juice turns it into a sound + a small shake. +signal resisted(kind: String, at: Vector3) +## A hit that hurt but didn't kill. `frac` is remaining hp, 0..1. +signal damaged(kind: String, at: Vector3, frac: float) var _shattered := false var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves @@ -47,6 +57,8 @@ var _released := false # so a piece is only released once (idempotent, cycle-s func _ready() -> void: add_to_group("smashable") var p: Dictionary = PROFILES.get(kind, PROFILES["wood"]) + _hp_max = float(p["hp"]) * maxf(toughness_scale, 0.01) + _hp = _hp_max physics_material_override = PhysicsMaterial.new() physics_material_override.bounce = p["bounce"] if start_frozen: @@ -68,18 +80,38 @@ func _on_body_entered(_body: Node) -> void: if _speed >= float(p["brittle_speed"]): shatter(Vector3.ZERO) -## Called by the hammer. Bosses (hits_to_break > 1) soak hits and visibly rock before -## the final blow shatters them; a normal prop (hits_to_break == 1) breaks on hit one. -func smash(impulse: Vector3) -> void: +## Take a swing. `damage` is what the weapon deals to THIS material (see Weapon.vs); +## the default of -1 means "whatever it takes", which keeps thrown-object and scripted +## smashes working without needing a weapon. +## +## Three outcomes, and the middle one is the point of the whole matrix: +## damage kills it ....... shatter +## damage dents it ....... knockback + `damaged`, so heavy props are a fight +## damage is futile ...... `resisted` — the clank that tells you to switch weapon +func smash(impulse: Vector3, damage: float = -1.0) -> void: if _shattered: return - _hits_taken += 1 - if _hits_taken < hits_to_break: - # survives this hit — a physical knockback so the boss reacts to the beating - sleeping = false - apply_central_impulse(impulse.limit_length(6.0) * 0.25) + if damage < 0.0: + shatter(impulse) return - shatter(impulse) + if damage < _hp_max * 0.10: + # too weak to matter: bounce off, make a noise, leave the prop intact + sleeping = false + apply_central_impulse(impulse.limit_length(4.0) * 0.10) + resisted.emit(kind, global_position) + return + _hp -= damage + if _hp <= 0.0: + shatter(impulse) + return + # survived — a physical knockback so it visibly reacts to the beating + sleeping = false + apply_central_impulse(impulse.limit_length(6.0) * 0.25) + damaged.emit(kind, global_position, clampf(_hp / _hp_max, 0.0, 1.0)) + +## Remaining hp as 0..1, for HUD damage reads. +func hp_frac() -> float: + return clampf(_hp / maxf(_hp_max, 0.001), 0.0, 1.0) func shatter(impulse: Vector3) -> void: if _shattered: diff --git a/game/scripts/ViewModel.gd b/game/scripts/ViewModel.gd new file mode 100644 index 0000000..cf4d5d7 --- /dev/null +++ b/game/scripts/ViewModel.gd @@ -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) diff --git a/game/scripts/ViewModel.gd.uid b/game/scripts/ViewModel.gd.uid new file mode 100644 index 0000000..79b2f05 --- /dev/null +++ b/game/scripts/ViewModel.gd.uid @@ -0,0 +1 @@ +uid://bxay77q0hgm4x diff --git a/game/scripts/Weapon.gd b/game/scripts/Weapon.gd new file mode 100644 index 0000000..8e453db --- /dev/null +++ b/game/scripts/Weapon.gd @@ -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 diff --git a/game/scripts/Weapon.gd.uid b/game/scripts/Weapon.gd.uid new file mode 100644 index 0000000..9c4186c --- /dev/null +++ b/game/scripts/Weapon.gd.uid @@ -0,0 +1 @@ +uid://dt82xm6dc0rey diff --git a/tools/gen_fps_arms.py b/tools/gen_fps_arms.py new file mode 100644 index 0000000..a2211f3 --- /dev/null +++ b/tools/gen_fps_arms.py @@ -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() diff --git a/tools/gen_viewmodel.py b/tools/gen_viewmodel.py new file mode 100644 index 0000000..4819909 --- /dev/null +++ b/tools/gen_viewmodel.py @@ -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()