Compare commits

..
13 Commits
Author SHA1 Message Date
Nicholas ButzkeandClaude Fable 5 d0c746d084 feat: Taila — second anime character through a hardened import pipeline
New selectable skin: Taila (original anime character by Partaevil,
CC-BY, license kept alongside the model), cel-shaded with ink outlines,
running the full 18-clip animation set + the new rifle-hold pose layer.

Pipeline hardening learned the hard way (each of these produced a broken
character before the fix):
- tools/strip_rig.py: strips a foreign rig (VRoid/Mixamo names bake FLAT
  clips in the name-based retarget), keeps only meshes skinned to that rig
  (scene props were joining into the player model), recentres feet-on-origin,
  and reroutes image textures into Principled base color for re-export.
- tools/unlit_to_pbr.py: KHR_materials_unlit anime models carry their albedo
  in emissiveTexture over a black base — Blender 5.1 drops the texture on
  import, rendering the character pitch black. Rewrites the GLB JSON to
  standard textured PBR before Blender ever sees it.

Also dropped the Elle import attempt: her rest pose is seated (posed scene),
which the T-pose autorig cannot use.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:07:17 -04:00
Nicholas ButzkeandClaude Fable 5 6c227b5538 feat: real two-hand rifle hold — armed animations finally read as a shooter
The clips were playing all along, but every locomotion state used unarmed
jog arms while the gun floated at the hip, so nothing looked animated.

- ShooterPoseModifier now REPLACES the arm chain with a direction-based FK
  rifle hold (low-ready at hip, shouldered on ADS) instead of nudging the
  unarmed clip additively; the wrist is solved so the muzzle points exactly
  along the aim line, gun kept upright (no-roll constraint).
- Per-arm release logic: reload/throw/hit one-shots, dance, death, the
  slide ground-brace, wall-run reach and grapple free the authored clips.
- Third-person weapon: fixed re-hide on weapon swap while in third person,
  scaled to character proportions, gripped along the hand.
- Armed idle uses Idle (arms owned by the hold) instead of arms-crossed
  PistolIdle base.
- FP viewmodel arms: character-styled sleeve + teal cuff + skin hand
  instead of the blue slabs.
- debug/anim_capture.gd: front+side screenshots of every movement state,
  one-shot action and ADS for visual regression of the model pipeline.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 17:55:05 -04:00
Nicholas ButzkeandClaude Fable 5 39af109893 feat: first-person reload animation on the viewmodel
The weapon dips down-and-inward with a roll when a reload starts, holds
through the reload, and rises as it completes — driven by the weapon's
actual reload_timer so the motion always matches the reload duration.
Complements the reload ring UI and the third-person reload clip.

Smoke 0 failures, movement 11/11.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 12:04:49 -04:00
Nicholas ButzkeandClaude Fable 5 7920aecec5 feat: player animation quality pass — aim follow, reload/throw, recoil
Third-person player characters now animate every action with readable
intent (all networked, cel style preserved):

- Look-around: upper body follows the owner's camera pitch — distributed
  over spine/neck/head so aiming up/down reads on the whole silhouette
  (driven by the already-synced HeadPivot rotation on remotes)
- Reload: the baked PistolReload clip plays as a one-shot whenever the
  equipped weapon starts reloading (local edge-detect poll -> new synced
  action counter replays it on every peer)
- Grenade throw: new Throw clip baked from the library's overhead swing
  (Sword_Attack retarget); triggers on throw, synced the same way
- Shot recoil: visible kick on the shooter's model (shoulders snap back,
  forearms rise, fast decay) driven by the existing fire-effects RPC —
  remote players' shots now look like shots
- Slide: trailing arm braces against the ground for balance on top of the
  feet-first pose
- Armed idle confirmed in capture: weapon held at ready, not arms-down

Plumbing: synced_action/synced_action_seq added to all three player
spawners' replication configs; ACTIONS table on the model maps names to
clips + lock times.

Verified: 18 clips resolve (incl Throw), smoke 0 failures, movement
11/11, acoustics PASSED; third-person run + armed idle captures clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 12:03:30 -04:00
Nicholas ButzkeandClaude Fable 5 5224a455a6 feat: screen-space ink edge pass — drawn outlines on everything
New assets/shaders/ink_edge.gdshader: fullscreen depth+normal discontinuity
detection draws thin ink lines over the whole scene — the renderer-level
line pass anime-styled games use. Works on every model regardless of mesh
topology (kit buildings, cars, heroes, characters), with distance fade so
the skyline stays clean. Attached via LevelEnvironment so every map gets
it for whatever camera renders (first-person, third-person, spectator).

Street capture verified: crisp uniform outlines citywide, no artifacts.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 11:54:55 -04:00
Nicholas ButzkeandClaude Fable 5 911066875b feat: hero batch 3 — pedestrian footbridge + viaduct station platforms
- footbridge.glb: 18m truss footbridge over the central avenue one block
  south of the rail line — X-pattern teal trusses, mesh railings, real
  stair flights at both ends. Deck and stairs are WALKABLE (box deck
  collider + invisible ramp colliders over the stair visuals): a third
  elevated crossing route between the viaduct and the plaza, and a new
  grapple anchor over the avenue
- platform.glb: station platform strips on the viaduct deck flanking the
  crossing — canopy on posts, benches, line signs; dresses the deck route
  players actually run
- Fixed a parse error from an undefined color constant caught by the
  capture loop (blank render -> error -> fix -> verified)

Movement 11/11, smoke 0 failures; full-city aerial verified.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:10:05 -04:00
Nicholas ButzkeandClaude Fable 5 18f50adb78 feat: hero asset batch 2 — yatai, koban, phone booths, bus stops, bikes
Second round of original Blender-kitbashed street furniture
(tools/build_hero_assets.py):

- yatai.glb: ramen cart — noren curtain strips on a hanger bar, counter,
  wheels, three stools, paper lantern, red roof with ink trim; one at
  every market mouth (wood collider)
- koban.glb: police box with teal roof, door, framed window, sign board
  and red lamp — watches the central crossing from the SE plaza corner
- phonebooth.glb: green-framed glass booth on ~25% of retail corners
- busstop.glb: bench + glass canopy + yellow sign disc, alternating
  blocks along the central avenue, both directions
- bicycle.glb: torus-wheeled bikes with frame/seat/basket, parked in
  seeded rows of 2-4 beside building fronts

All toonified with ink outlines; colliders acoustics-tagged (wood/
concrete/glass/metal). Movement 11/11, smoke 0, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:04:54 -04:00
Nicholas ButzkeandClaude Fable 5 7f9a579b63 feat: original kitbashed hero assets built in Blender
First bespoke art authored for the project (tools/build_hero_assets.py,
headless Blender 5.1 -> assets/props/hero/):

- konbini.glb: 8m convenience-store front — fascia + teal sign band with
  ink trim, framed windows with sills and window ads, recessed double
  glass door, angled awning, drink machine, hedge planter. Placed on
  ~1/3 of wide buildings (glass-tagged collider)
- station.glb: viaduct station entrance — canopy on pillars, real stair
  steps with handrails and balusters, sign totem; placed at the central
  avenue crossing of the rail line
- clock.glb: 10m plaza clock tower — tiered base, ringed column, 4 clock
  faces with hands, teal crown; anchors the south-west plaza cell
- torii.glb: proper torii with segmented curved kasagi, shimaki, nuki
  beam and plaque — replaces the box torii in every shrine courtyard
  (invisible pillar colliders keep gameplay identical)

All hero pieces run through the toon pipeline with fine ink outlines.
Every part beveled; flat cel palette matches the city.

Verified: city renders clean with hero pieces placed; movement 11/11,
smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 03:00:17 -04:00
Nicholas ButzkeandClaude Fable 5 6b3e363dfd feat: protruding double-sided shop signs over the sidewalks
1-2 per building at second-floor height on metal brackets, double-faced
with generated ad graphics — the perpendicular sign clutter that defines
Akihabara/anime-city streets and makes rows read dense from street level.

Verified via street capture; smoke test 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:53:03 -04:00
Nicholas ButzkeandClaude Fable 5 c8a85586bd feat: modeled market stalls, mega wall ads, traffic lights, awnings
Final detail pass — no gameplay object remains a plain box:

- Markets: box stalls replaced with modeled kit stalls (4 styles seeded),
  hidden box colliders keep the wood acoustics; hand carts at the market
  mouths; standing lanterns between stalls
- Shrines: hanging lanterns on the stone pillars, wooden fences flanking
  the torii approach
- Towers: giant mid-height wall ads on the street faces (the anime-city
  mega-ad look), on top of crown boards and corner columns
- Storefront awnings (kit detail piece) on ~40% of buildings
- Traffic signals at every central-avenue crossing, diagonal from the
  streetlights
- Fantasy Town Kit (CC0) installed at assets/props/town

Verified via street capture (wall-scale glyph ads, pharmacy signs,
banner ads, stalls, carts); movement 11/11, smoke 0, acoustics PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:50:28 -04:00
Nicholas ButzkeandClaude Fable 5 648872a16d feat: ad-graphic layer — generated posters, billboards and sign columns
The city's flat color sign panels are now real graphic designs, closing
the street-level "graphic density" that anime cities live on:

- tools/generate_ads.py: procedural ad library (24 textures) — bold
  two-tone layouts (diagonal splits, bands, circles, stripes) with
  fake-glyph text blocks that read as kanji/kana from gameplay distance,
  inked borders, seeded palette system
- Every building gets 1-3 street-level posters plastered on its facade
- Vertical sign columns render a stacked-glyph column texture
- Rooftop billboards, tower crown boards (both faces) and the central
  4-face screen tower all show distinct ad graphics
- Posters under the viaduct arches on every pier
- All panels lit (albedo + emission of the same texture) so they glow
  as signage without blooming out

Verified via street capture (glyph posters, pharmacy-cross signs, ad
boards visible down the avenue); movement 11/11, smoke 0 failures,
acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:44:55 -04:00
Nicholas ButzkeandClaude Fable 5 56db9eea59 feat: parked cars across the city (Kenney Car Kit, CC0)
Streets are now populated: seeded parking lanes hug the curbs of every
N-S street with sedans, taxis, vans, SUVs, hatchbacks, delivery trucks
and the occasional police car (~100+ across the city).

- Wheels attach at the models' wheel-socket empties at spawn
- Metal-tagged box colliders: cars are cover that sounds like cover
- Toonified + darkened base tint (kit albedo clips under the map light)
  with per-car hue jitter for varied paint jobs
- Slight rotation jitter so parking reads natural, not stamped

Verified via street capture (colored cars parked down the avenue, no
clipping/glow); movement 11/11, smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:39:35 -04:00
Nicholas ButzkeandClaude Fable 5 04d91dea31 feat: street-level detail pass — real roads, streetlights, trees, cables
Closes more of the gap to hand-authored city art at eye level:

- Kenney City Kit Roads (CC0) installed: every street in the 10x10 grid is
  now real road meshes — lane-marked straights (single stretched mesh per
  segment) and line-marked crossroad tiles at all 81 intersections, tinted
  to sit under the toon lighting without clipping
- Kit streetlights (curved arms over the road) along both central avenues,
  with slim pole colliders kept for grapples
- Kenney Nature Kit trees replace the cone/box stand-ins in shrine
  courtyards and plaza planters (original kit materials kept — the toon
  swap mangled their vertex colors into cyan)
- Overhead cable bundles sag across the east-west streets (the classic
  urban-Japan sky clutter), drawn as ink lines; central avenue kept clear
- Construction sites get cones + barriers from the roads kit
- Per-building tint jitter so repeated kit models read as distinct shops

Verified via street/aerial captures (dark marked asphalt, green trees,
detailed facades); movement 11/11, smoke 0 failures, acoustics ALL PASSED.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 02:33:31 -04:00
90 changed files with 1628 additions and 136 deletions
Binary file not shown.
+7
View File
@@ -6,6 +6,13 @@
"description": "Hatsune Miku \u2014 Virtual Idol", "description": "Hatsune Miku \u2014 Virtual Idol",
"model": "res://assets/characters/skins/miku.glb", "model": "res://assets/characters/skins/miku.glb",
"unlocked": true "unlocked": true
},
{
"id": "taila",
"name": "Taila",
"description": "Original anime character by Partaevil (CC-BY)",
"model": "res://assets/characters/skins/taila.glb",
"unlocked": true
} }
] ]
} }
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Taila | Original work",
"uid": "8ae231b61fc34827be30e2a1edc5b811",
"author": "Partaevil",
"author_url": "https://sketchfab.com/Partaevil",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/taila-original-work-8ae231b61fc34827be30e2a1edc5b811"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

+28
View File
@@ -0,0 +1,28 @@
Car Kit (3.1)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 02-04-2026 14:03
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You can use this content for personal, educational, and commercial purposes.
Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)
------------------------------
• Website : www.kenney.nl
• Donate : www.kenney.nl/donate
• Patreon : patreon.com/kenney
Follow on social media for updates:
• Twitter: twitter.com/KenneyNL
• BlueSky: kenney.bsky.social
• Instagram: instagram.com/kenney_nl
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
Fantasy Town Kit (2.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 03-08-2025 13:56
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You can use this content for personal, educational, and commercial purposes.
Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)
------------------------------
• Website : www.kenney.nl
• Donate : www.kenney.nl/donate
• Patreon : patreon.com/kenney
Follow on social media for updates:
• Twitter: twitter.com/KenneyNL
• BlueSky: kenney.bsky.social
• Instagram: instagram.com/kenney_nl
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+57
View File
@@ -0,0 +1,57 @@
shader_type spatial;
render_mode unshaded, fog_disabled, depth_draw_never, depth_test_disabled, cull_disabled;
// Screen-space ink outline: detects depth and normal discontinuities and
// draws thin dark edges over the scene — the drawn-line pass that unifies
// every model (kit, hero, character) into the cel look. Attach to a
// fullscreen quad (POSITION override) so it runs for whatever camera renders.
uniform sampler2D screen_tex : hint_screen_texture, filter_linear;
uniform sampler2D depth_tex : hint_depth_texture, filter_nearest;
uniform sampler2D normal_tex : hint_normal_roughness_texture, filter_nearest;
uniform vec4 ink_color : source_color = vec4(0.07, 0.06, 0.09, 1.0);
uniform float depth_threshold : hint_range(0.0, 4.0) = 1.1;
uniform float normal_threshold : hint_range(0.0, 1.0) = 0.55;
uniform float edge_strength : hint_range(0.0, 1.0) = 0.8;
uniform float max_distance : hint_range(10.0, 500.0) = 220.0;
float linear_depth(vec2 uv, mat4 inv_proj) {
float d = texture(depth_tex, uv).r;
vec4 ndc = vec4(uv * 2.0 - 1.0, d, 1.0);
vec4 view = inv_proj * ndc;
return -view.z / view.w;
}
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
vec2 px = 1.0 / VIEWPORT_SIZE;
vec2 uv = SCREEN_UV;
float dc = linear_depth(uv, INV_PROJECTION_MATRIX);
float dl = linear_depth(uv - vec2(px.x, 0.0), INV_PROJECTION_MATRIX);
float dr = linear_depth(uv + vec2(px.x, 0.0), INV_PROJECTION_MATRIX);
float du = linear_depth(uv - vec2(0.0, px.y), INV_PROJECTION_MATRIX);
float dd = linear_depth(uv + vec2(0.0, px.y), INV_PROJECTION_MATRIX);
// Depth edge: neighbor difference scaled by distance so far geometry
// doesn't dissolve into noise.
float ddiff = abs(dl - dr) + abs(du - dd);
float depth_edge = step(depth_threshold * (0.5 + dc * 0.08), ddiff);
// Normal edge: crease lines on same-depth corners.
vec3 nc = texture(normal_tex, uv).xyz * 2.0 - 1.0;
vec3 nl = texture(normal_tex, uv - vec2(px.x, 0.0)).xyz * 2.0 - 1.0;
vec3 nu = texture(normal_tex, uv - vec2(0.0, px.y)).xyz * 2.0 - 1.0;
float ndiff = length(nc - nl) + length(nc - nu);
float normal_edge = step(normal_threshold, ndiff) * step(ddiff, depth_threshold);
float fade = 1.0 - smoothstep(max_distance * 0.6, max_distance, dc);
float edge = max(depth_edge, normal_edge * 0.6) * edge_strength * fade;
vec3 scene = texture(screen_tex, uv).rgb;
ALBEDO = mix(scene, ink_color.rgb, edge);
}
+1
View File
@@ -0,0 +1 @@
uid://dwst4d2srljqj
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+259 -33
View File
@@ -49,6 +49,7 @@ const CLIP_FALLBACKS := {
"PistolIdle": ["PistolIdle", "Idle"], "PistolIdle": ["PistolIdle", "Idle"],
"PistolShoot": ["PistolShoot"], "PistolShoot": ["PistolShoot"],
"PistolReload": ["PistolReload"], "PistolReload": ["PistolReload"],
"Throw": ["Throw", "Hit"],
} }
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch", const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
@@ -59,10 +60,18 @@ const BLEND_TIME := 0.15
## Per-clip blend overrides: snappy moves cut fast, locomotion cross-fades. ## Per-clip blend overrides: snappy moves cut fast, locomotion cross-fades.
const BLEND_TIMES := { const BLEND_TIMES := {
"Dash": 0.06, "Jump": 0.08, "Hit": 0.05, "Land": 0.08, "Dash": 0.06, "Jump": 0.08, "Hit": 0.05, "Land": 0.08,
"Slide": 0.1, "Death": 0.1, "Slide": 0.1, "Death": 0.1, "Throw": 0.06, "PistolReload": 0.12,
"Idle": 0.25, "PistolIdle": 0.25, "Walk": 0.2, "Run": 0.2, "Sprint": 0.2, "Idle": 0.25, "PistolIdle": 0.25, "Walk": 0.2, "Run": 0.2, "Sprint": 0.2,
} }
## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter.
const ACTIONS := {
"reload": ["PistolReload", 1.15],
"throw": ["Throw", 0.55],
"shoot": ["PistolShoot", 0.2],
}
var skeleton: Skeleton3D var skeleton: Skeleton3D
var animation_player: AnimationPlayer var animation_player: AnimationPlayer
var loaded: bool = false var loaded: bool = false
@@ -84,6 +93,7 @@ var _cur_fwd: float = 0.0
var _cur_ads: float = 0.0 var _cur_ads: float = 0.0
var _cur_slide: float = 0.0 var _cur_slide: float = 0.0
var _cur_wall: float = 0.0 var _cur_wall: float = 0.0
var _owner_visible: bool = false
const POSE_SMOOTH := 10.0 const POSE_SMOOTH := 10.0
@@ -188,6 +198,7 @@ func _find_clip(available: PackedStringArray, wanted: String) -> String:
## toggle calls this with on=true to reveal the full animated model. Either way ## toggle calls this with on=true to reveal the full animated model. Either way
## the model keeps casting shadows and stays visible to other players. ## the model keeps casting shadows and stays visible to other players.
func set_owner_visible(on: bool) -> void: func set_owner_visible(on: bool) -> void:
_owner_visible = on
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \ var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_shadow_mode_recursive(self, mode) _set_shadow_mode_recursive(self, mode)
@@ -222,6 +233,24 @@ func set_dancing(on: bool) -> void:
_dancing = on _dancing = on
## Play a named gameplay action (reload / throw / shoot) as a one-shot.
func play_action(action: String) -> void:
if ACTIONS.has(action):
play_oneshot(ACTIONS[action][0], ACTIONS[action][1])
## Aim pitch in radians (up positive) — the upper body follows the camera.
func set_aim_pitch(pitch: float) -> void:
if _pose_mod:
_pose_mod.aim_pitch = clampf(pitch, -1.2, 1.2)
## Kick the pose recoil (fires on every shot, local echo or remote replay).
func add_gun_recoil(strength: float = 1.0) -> void:
if _pose_mod:
_pose_mod.recoil = minf(_pose_mod.recoil + strength, 1.5)
## Same contract as HumanoidModel.update_state(). Called by the movement ## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state. ## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void: func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
@@ -256,9 +285,8 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
clip = "Run" clip = "Run"
elif speed > 0.5: elif speed > 0.5:
clip = "Walk" clip = "Walk"
elif is_holding_weapon and _resolved_clips.has("PistolIdle"): # Armed idle uses the plain Idle clip — the rifle-hold pose layer
# Armed idle: weapon actually held ready instead of arms-down # owns the arms, so the odd arms-crossed PistolIdle base reads worse.
clip = "PistolIdle"
"air": "air":
# Rising = jump, falling = the fall loop. # Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall" clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
@@ -324,6 +352,30 @@ func _process(delta: float) -> void:
_pose_mod.slide = _cur_slide _pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall _pose_mod.wall = _cur_wall
# Two-hand rifle hold: owns the arms whenever a weapon is held, EXCEPT when
# a one-shot clip (reload/throw/hit), an emote, or a state whose arms matter
# (death, dance) needs the authored animation to read through.
var st: String = _pose_mod.state
var clip_owns_arms := _oneshot_lock > 0.0 or _dancing or st == "death"
var hold_r := 0.0
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
hold_r = 1.0
hold_l = 1.0
match st:
"slide":
hold_l = 0.0 # trailing arm braces the ground
"wall_run":
# The wall-side arm reaches for the wall.
if _cur_wall > 0.05:
hold_r = 0.0
elif _cur_wall < -0.05:
hold_l = 0.0
"grapple":
hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r
_pose_mod.hold_l_target = hold_l
func _play_clip(canonical: String, restart: bool = false) -> void: func _play_clip(canonical: String, restart: bool = false) -> void:
if not animation_player or not _resolved_clips.has(canonical): if not animation_player or not _resolved_clips.has(canonical):
@@ -368,13 +420,21 @@ func set_weapon(script_path: String) -> void:
w.set_process_input(false) w.set_process_input(false)
# Owner's first-person view must not see their own held weapon (it # Owner's first-person view must not see their own held weapon (it
# sits right in front of the lens as a huge blob) — shadows only, # sits right in front of the lens as a huge blob) — shadows only,
# same as the body. set_owner_visible reveals it in third person. # same as the body. Skip when the owner is already in third person
if shadows_only or first_person_mode: # (weapon swap while toggled), else the new weapon comes up invisible.
if (shadows_only or first_person_mode) and not _owner_visible:
_set_shadows_recursive(w) _set_shadows_recursive(w)
# Undo the first-person viewmodel placement from the weapon's _ready. # Undo the first-person viewmodel placement from the weapon's _ready:
w.position = Vector3(0.0, 0.08, 0.03) # lie along the hand's grip, scaled down to character proportions.
w.rotation_degrees = Vector3(0, 90, 0) w.position = Vector3(-0.02, 0.07, 0.0)
w.scale = Vector3(0.8, 0.8, 0.8) w.rotation_degrees = Vector3(0, 90, -90)
w.scale = Vector3(0.75, 0.75, 0.75)
# Tell the pose layer the gun's axes in hand-bone space so it can
# aim the wrist to point the muzzle exactly where the player looks.
if _pose_mod:
var b: Basis = w.transform.basis.orthonormalized()
_pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1)
_pose_mod.gun_up_hand = b * Vector3(0, 1, 0)
) )
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"]) var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
@@ -437,8 +497,19 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var ads: float = 0.0 # 0 hip .. 1 aiming var ads: float = 0.0 # 0 hip .. 1 aiming
var slide: float = 0.0 # 0 .. 1 slide blend var slide: float = 0.0 # 0 .. 1 slide blend
var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean) var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean)
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
var recoil: float = 0.0 # decaying shot kick
var state: String = "idle" var state: String = "idle"
var weapon_held: bool = false var weapon_held: bool = false
# Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0
var hold_l_target: float = 0.0
var _hold_r: float = 0.0
var _hold_l: float = 0.0
var _time: float = 0.0
# The attached gun's forward/up axes in hand-bone space (set on set_weapon).
var gun_fwd_hand: Vector3 = Vector3.ZERO
var gun_up_hand: Vector3 = Vector3.UP
# Tuning (radians). Positive pitch leans forward; positive roll leans right. # Tuning (radians). Positive pitch leans forward; positive roll leans right.
const LEAN_ROLL := 0.30 const LEAN_ROLL := 0.30
@@ -449,9 +520,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
const SLIDE_KNEE := 0.55 # shins straighten against the crouch clip's bend const SLIDE_KNEE := 0.55 # shins straighten against the crouch clip's bend
const WALL_PITCH := 0.2 # forward drive lean during a wall run const WALL_PITCH := 0.2 # forward drive lean during a wall run
const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall
const ADS_LIFT := 1.05 # upper-arm raise toward aim at full ADS const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms
const ADS_SWING := 0.6 # swing arms in toward centre-front on ADS
const ADS_FOREARM := 0.55 # forearm bend to bring the weapon up on ADS
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"] const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
@@ -474,14 +543,50 @@ class ShooterPoseModifier extends SkeletonModifier3D:
return return
if not _resolved: if not _resolved:
_resolve() _resolve()
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
_time += delta
var t := 1.0 - exp(-HOLD_SMOOTH * delta)
_hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t)
_apply_lean(skel) _apply_lean(skel)
if absf(aim_pitch) > 0.01:
_apply_aim_pitch(skel)
if slide > 0.01: if slide > 0.01:
_apply_slide(skel) _apply_slide(skel)
if absf(wall) > 0.01: if absf(wall) > 0.01:
_apply_wall_lean(skel) _apply_wall_lean(skel)
if weapon_held: if _hold_r > 0.01 or _hold_l > 0.01:
_apply_weapon(skel) _apply_rifle_hold(skel)
if recoil > 0.01:
_apply_recoil(skel)
recoil = lerpf(recoil, 0.0, 0.25)
# Upper body follows the camera pitch: distributed over spine/neck/head
# so looking up/down reads on the whole silhouette, not just the head.
func _apply_aim_pitch(skel: Skeleton3D) -> void:
# Positive camera pitch (looking up) arches the torso back.
var per := Quaternion.IDENTITY.slerp(
Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.55), 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var head_q := Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.45)
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
# Shot kick: shoulders snap back and up, forearms rise; decays fast.
func _apply_recoil(skel: Skeleton3D) -> void:
var k := recoil
var back := Quaternion(Vector3(1, 0, 0), -0.12 * k)
for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back)
var arm_up := Quaternion(Vector3(1, 0, 0), -0.3 * k)
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), arm_up)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), arm_up)
_add_space(skel, _idx.get("DEF-forearm.R", -1), Quaternion(Vector3(1, 0, 0), -0.22 * k))
# Distribute a skeleton-space lean across the spine bones. # Distribute a skeleton-space lean across the spine bones.
func _apply_lean(skel: Skeleton3D) -> void: func _apply_lean(skel: Skeleton3D) -> void:
@@ -527,25 +632,146 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var straighten := Quaternion(Vector3(1, 0, 0), SLIDE_KNEE * slide) var straighten := Quaternion(Vector3(1, 0, 0), SLIDE_KNEE * slide)
_add_space(skel, _idx.get("DEF-shin.R", -1), straighten) _add_space(skel, _idx.get("DEF-shin.R", -1), straighten)
_add_space(skel, _idx.get("DEF-shin.L", -1), straighten) _add_space(skel, _idx.get("DEF-shin.L", -1), straighten)
# Trailing arm braces back-and-down against the ground for balance.
var brace := Quaternion(Vector3(1, 0, 0), 0.9 * slide) \
* Quaternion(Vector3(0, 0, 1), -0.5 * slide)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), brace)
_add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide))
# Weapon hold. At the hip the base clip already keeps the arms down with the # ── Two-hand rifle hold ──────────────────────────────────────────────────
# weapon (attached to the hand) at the side, so we leave it alone. On ADS we # REPLACES the arm-chain rotations from the base clip with a deterministic
# additively lift both arms forward-up toward an aiming pose. Rotations are # FK pose: each arm bone is aimed along an art-directed skeleton-space
# about the skeleton's X axis (the shoulder line), so the down arms swing # DIRECTION (low-ready at the hip, shouldered on ADS), so the hold looks
# forward to eye level. # identical in every locomotion state — no unarmed jog arms flailing
func _apply_weapon(skel: Skeleton3D) -> void: # around a floating gun. Directions are in skeleton space: character faces
if ads < 0.01: # +Z, up +Y, character-right -X.
return
# Raise about X (down arm -> forward) and swing about Y so each arm comes # Right arm: gun hand — elbow at the ribs, hand ahead of the right hip,
# IN toward centre-front instead of splaying out to the side. Right arm # rifle line pointing forward-down.
# is on -X so it swings +Y; the left mirrors it. const R_UA_HIP := Vector3(-0.30, -0.90, 0.28)
var lift := Quaternion(Vector3(1, 0, 0), -ADS_LIFT * ads) const R_FA_HIP := Vector3(0.25, 0.15, 0.95)
var swing := ADS_SWING * ads # Left arm: support hand crosses to the foregrip ahead of the belly.
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), Quaternion(Vector3(0, 1, 0), swing) * lift) const L_UA_HIP := Vector3(0.32, -0.80, 0.42)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), Quaternion(Vector3(0, 1, 0), -swing) * lift) const L_FA_HIP := Vector3(-0.25, 0.10, 0.96)
var bend := Quaternion(Vector3(1, 0, 0), -ADS_FOREARM * ads) # ADS: both arms rise with bent elbows, hands stack along the eye line.
_add_space(skel, _idx.get("DEF-forearm.R", -1), bend) const R_UA_ADS := Vector3(-0.30, -0.60, 0.70)
_add_space(skel, _idx.get("DEF-forearm.L", -1), bend) const R_FA_ADS := Vector3(0.35, 0.45, 0.85)
const L_UA_ADS := Vector3(0.28, -0.50, 0.80)
const L_FA_ADS := Vector3(-0.25, 0.45, 0.88)
# Twist about the bone line (radians) to keep elbows/palms natural.
const R_UA_TWIST := 0.3; const L_UA_TWIST := -0.3
const L_HAND_TWIST := 0.5
const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready
func _apply_rifle_hold(skel: Skeleton3D) -> void:
var breathe := sin(_time * 2.2) * 0.015 + fwd * 0.03
var kick := recoil * 0.2
# Aim pitch tilts the whole hold on ADS; recoil kicks the muzzle up.
var pitch := (-aim_pitch * ads) - kick - breathe
var q_pitch := Quaternion(Vector3(1, 0, 0), pitch)
var ua_r: Vector3 = q_pitch * R_UA_HIP.lerp(R_UA_ADS, ads).normalized()
var fa_r: Vector3 = q_pitch * R_FA_HIP.lerp(R_FA_ADS, ads).normalized()
var ua_l: Vector3 = q_pitch * L_UA_HIP.lerp(L_UA_ADS, ads).normalized()
var fa_l: Vector3 = q_pitch * L_FA_HIP.lerp(L_FA_ADS, ads).normalized()
var g_fa_r := _aim_chain(skel, "DEF-upper_arm.R", "DEF-forearm.R",
ua_r, fa_r, R_UA_TWIST, _hold_r)
var g_fa_l := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L",
ua_l, fa_l, L_UA_TWIST, _hold_l)
# Gun hand: orient the wrist so the MUZZLE points exactly along the aim
# line (forward-down at low-ready, camera pitch on ADS), gun kept
# upright. This is what makes the weapon read "aimed" instead of
# dangling at whatever angle the wrist twist happens to produce.
if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \
and gun_fwd_hand.length_squared() > 0.5:
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick * 2.0
var d := Quaternion(Vector3(1, 0, 0), gun_pitch) * Vector3(0, 0, 1)
var hand: int = _idx.get("DEF-hand.R", -1)
if hand >= 0:
var arc := Quaternion(gun_fwd_hand.normalized(), d)
# Kill the roll: rotate about the aim line so the gun's up
# vector lands in the vertical plane of the aim direction.
var up_now := arc * gun_up_hand.normalized()
var side := d.cross(Vector3.UP)
if side.length_squared() > 0.001:
var up_ideal := side.normalized().cross(d).normalized()
var up_flat := (up_now - d * up_now.dot(d)).normalized()
var roll := up_flat.signed_angle_to(up_ideal, d)
arc = Quaternion(d, roll) * arc
_blend_local(skel, hand, g_fa_r.inverse() * arc, _hold_r)
if OS.has_environment("GUN_POSE_DEBUG"):
var fa_actual := skel.get_bone_global_pose(
_idx.get("DEF-forearm.R", -1)).basis.get_rotation_quaternion()
var hand_actual := skel.get_bone_global_pose(hand).basis.get_rotation_quaternion()
print("MOD DEBUG d=", d,
" fa_target=", g_fa_r, " fa_actual=", fa_actual,
" hand_global*v=", hand_actual * gun_fwd_hand)
# Support hand: follow the forearm line with a fixed palm twist.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY:
var hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l_idx: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l_idx >= 0:
var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
var fa_o := skel.get_bone_global_rest(fa_l_idx).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(fa_l, L_HAND_TWIST) \
* Quaternion(fa_rest_dir, fa_l) * hand_rest_q
_blend_local(skel, hand_l, g_fa_l.inverse() * g_hand, _hold_l)
# Aim an upper-arm/forearm chain along the given directions with exact FK:
# desired global orientation = (shortest arc from the bone's rest line to
# the target dir, plus a twist about that line) ⊕ rest, each local pose
# derived against the parent's posed global so there is no drift.
# Returns the forearm's target global rotation (IDENTITY when skipped).
func _aim_chain(skel: Skeleton3D, ua_name: String, fa_name: String,
ua_dir: Vector3, fa_dir: Vector3, ua_twist: float,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
if ua < 0 or fa < 0:
return Quaternion.IDENTITY
# Bone lines at rest (upper arm -> forearm -> hand joint origins).
var ua_o := skel.get_bone_global_rest(ua).origin
var fa_o := skel.get_bone_global_rest(fa).origin
var fa_children := skel.get_bone_children(fa)
var fa_tip := skel.get_bone_global_rest(fa_children[0]).origin \
if fa_children.size() > 0 else fa_o + (fa_o - ua_o)
var ua_rest_dir := (fa_o - ua_o).normalized()
var fa_rest_dir := (fa_tip - fa_o).normalized()
var ua_rest_q := skel.get_bone_global_rest(ua).basis.get_rotation_quaternion()
var fa_rest_q := skel.get_bone_global_rest(fa).basis.get_rotation_quaternion()
# Desired global rotations.
var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q
var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q
# Local poses against the actual (clip-posed) parent for the shoulder
# link, then against our own target down the chain.
var parent := skel.get_bone_parent(ua)
var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \
if parent >= 0 else Quaternion.IDENTITY
_blend_local(skel, ua, g_parent.inverse() * g_ua, w)
_blend_local(skel, fa, g_ua.inverse() * g_fa, w)
if OS.has_environment("GUN_POSE_DEBUG") and ua_name.ends_with(".R"):
var ua_actual := skel.get_bone_global_pose(ua).basis.get_rotation_quaternion()
var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion()
print("CHAIN DEBUG w=", w,
" ua_t=", g_ua, " ua_a=", ua_actual,
" | fa_t=", g_fa, " fa_a=", fa_actual,
" | ua_dir_t=", ua_dir, " ua_dir_a=", ua_actual * (ua_rest_q.inverse() * ua_rest_dir))
return g_fa
func _blend_local(skel: Skeleton3D, idx: int, target: Quaternion, w: float) -> void:
skel.set_bone_pose_rotation(idx,
skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w))
# Compose a skeleton-space rotation onto a bone's animated local pose. # Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void: func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
+168
View File
@@ -0,0 +1,168 @@
extends SceneTree
## Dev tool: screenshot every animation state on the active GLB skin.
## Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> [skin_id]
## Saves anim_<state>.png per state plus mid-action shots for reload/throw.
var _frames := 0
var _out_dir := "."
var _skin := "miku"
var _player: Node = null
var _model: Node = null
var _cam: Camera3D = null
# [tag, state, speed, crouch, hold_frames]
var _states := [
["idle", "idle", 0.0, false, 50],
["walk", "ground", 2.2, false, 50],
["run", "ground", 6.5, false, 50],
["sprint", "ground", 12.0, false, 50],
["crouch", "ground", 0.0, true, 50],
["crouchwalk", "ground", 2.0, true, 50],
["fall", "air", 4.0, false, 50],
["slide", "slide", 10.0, true, 50],
["dash", "dash", 14.0, false, 30],
["wallrun", "wall_run", 9.0, false, 50],
["grapple", "grapple", 10.0, false, 50],
["dance", "idle", 0.0, false, 60],
]
var _phase := 0 # index into _states, then actions after
var _phase_frame := 0
var _actions := [["reload", 30], ["throw", 18]]
var _action_i := 0
var _mode := "states" # states -> actions -> ads -> done
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
if args.size() > 1:
_skin = args[1]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var sm = root.get_node_or_null("SkinManager")
if sm:
sm.set_active_skin(_skin)
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
return false
if _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("get_visual_model") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("anim_capture: no player found")
return true
_model = _player.get_node_or_null("SkinnedModel")
if not _model:
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
return true
# Freeze gameplay driving so we control the animation state directly.
_player.set_physics_process(false)
_player.set_process(false)
_model.set_owner_visible(true)
# Give the model a weapon so armed poses read.
if _model.has_method("set_weapon"):
_model.set_weapon("res://weapons/ak47.gd")
# Camera: repositioned per shot (front + side). Model faces -Z.
_cam = Camera3D.new()
_cam.cull_mask &= ~(1 << 19) # don't render the FP viewmodel layer
current_scene.add_child(_cam)
_cam_to(Vector3(0.4, 0.9, -2.4))
_cam.current = true
return false
# Drive the forced state every frame so blends settle. Front shot two
# frames before the hold ends, side shot on the last frame (the camera
# hop needs a real rendered frame in between).
if _mode == "states":
var s: Array = _states[_phase]
if s[0] == "dance" and _model.has_method("set_dancing"):
_model.set_dancing(true)
_model.update_state(s[1], s[2], s[3])
_phase_frame += 1
var hold: int = int(s[4])
if _phase_frame == hold - 2:
_snap("anim_" + String(s[0]) + "_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= hold:
_snap("anim_" + String(s[0]) + "_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
if s[0] == "dance" and _model.has_method("set_dancing"):
_model.set_dancing(false)
if s[0] == "idle":
_debug_gun()
_phase_frame = 0
_phase += 1
if _phase >= _states.size():
_mode = "actions"
return false
if _mode == "actions":
var a: Array = _actions[_action_i]
if _phase_frame == 0:
_model.play_action(a[0])
_model.update_state("idle", 0.0, false)
_phase_frame += 1
var hold_a: int = int(a[1])
if _phase_frame == hold_a - 2:
_snap("anim_" + String(a[0]) + "_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= hold_a:
_snap("anim_" + String(a[0]) + "_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
_phase_frame = 0
_action_i += 1
if _action_i >= _actions.size():
_mode = "ads"
return false
if _mode == "ads":
_model.update_state("idle", 0.0, false)
if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 1.0)
_phase_frame += 1
if _phase_frame == 48:
_snap("anim_ads_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50:
_snap("anim_ads_s")
_debug_gun()
return true
return false
return true
func _debug_gun() -> void:
var pm = _model.get("_pose_mod")
if not pm:
return
var skel: Skeleton3D = _model.skeleton
var hand := skel.find_bone("DEF-hand.R")
var achieved := Vector3.ZERO
if hand >= 0 and pm.gun_fwd_hand.length_squared() > 0.01:
achieved = skel.get_bone_global_pose(hand).basis * pm.gun_fwd_hand
print("GUN DEBUG: fwd_hand=", pm.gun_fwd_hand, " ads=", pm.ads,
" hold_r=", pm._hold_r, " achieved_world_fwd=", achieved)
func _cam_to(offset: Vector3) -> void:
var base: Vector3 = _player.global_position
_cam.global_position = base + offset
_cam.look_at(base + Vector3(0, 0.25, 0), Vector3.UP)
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("anim_capture: saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://dc7dqp671fvya
-36
View File
@@ -1,36 +0,0 @@
extends SceneTree
## Dev tool: print AABBs of prop models used by the city builder.
const PATHS := [
"res://assets/props/roads/road-straight.glb",
"res://assets/props/roads/road-crossroad-line.glb",
"res://assets/props/roads/road-crossing.glb",
"res://assets/props/roads/light-curved.glb",
"res://assets/props/roads/light-curved-double.glb",
"res://assets/props/roads/construction-barrier.glb",
"res://assets/props/roads/construction-cone.glb",
"res://assets/props/nature/tree_default.glb",
"res://assets/props/nature/tree_detailed.glb",
"res://assets/props/nature/tree_oak.glb",
"res://assets/props/nature/tree_tall.glb",
]
func _initialize() -> void:
for p in PATHS:
var scene = load(p)
if scene == null:
print("measure: FAILED ", p)
continue
var inst: Node3D = scene.instantiate()
get_root().add_child(inst)
var aabb := AABB()
var first := true
for mi in inst.find_children("*", "MeshInstance3D", true, false):
var b: AABB = mi.global_transform * mi.get_aabb()
aabb = b if first else aabb.merge(b)
first = false
print("measure: %s size=%s min=%s" % [p.get_file(), aabb.size, aabb.position])
inst.queue_free()
quit()
-1
View File
@@ -1 +0,0 @@
uid://dd4g4l7mg66ro
+2
View File
@@ -295,6 +295,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_sp") client_rep_config.add_property(":synced_loadout_sp")
client_rep_config.add_property(":synced_loadout_melee") client_rep_config.add_property(":synced_loadout_melee")
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_action")
client_rep_config.add_property(":synced_action_seq")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
+3 -1
View File
@@ -35,9 +35,11 @@ func _process(_delta: float) -> bool:
if p.has_method("set_third_person") and p.is_multiplayer_authority(): if p.has_method("set_third_person") and p.is_multiplayer_authority():
p.set_third_person(true) p.set_third_person(true)
Input.action_press("move_forward") Input.action_press("move_forward")
elif _frames == 320: elif _frames == 300:
_shot("tp_run") _shot("tp_run")
Input.action_release("move_forward") Input.action_release("move_forward")
elif _frames == 338:
_shot("tp_idle")
# Aerial overview of the whole map # Aerial overview of the whole map
var cam := root.get_camera_3d() var cam := root.get_camera_3d()
if cam: if cam:
+51
View File
@@ -39,6 +39,13 @@ var footstep_player: AudioStreamPlayer
var footstep_streams: Array = [] var footstep_streams: Array = []
var land_player: AudioStreamPlayer var land_player: AudioStreamPlayer
var _remote_footstep_timer: float = 0.0 var _remote_footstep_timer: float = 0.0
# Networked one-shot animation actions (reload/throw): the owner bumps the
# sequence; remote peers play the named action when it changes.
@export var synced_action: String = ""
@export var synced_action_seq: int = 0
var _last_action_seq: int = 0
var _was_reloading: bool = false
var dash_player: AudioStreamPlayer var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer var hit_player: AudioStreamPlayer
@@ -509,6 +516,11 @@ func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name
if sid != "" and am.has_sound(sid): if sid != "" and am.has_sound(sid):
am.play_3d(sid, origin) am.play_3d(sid, origin)
# Visible shot kick on the remote shooter's model
var rv = get_visual_model()
if rv and rv.has_method("add_gun_recoil"):
rv.add_gun_recoil()
if is_hitscan: if is_hitscan:
# Spawn a standalone tracer directly into the scene # Spawn a standalone tracer directly into the scene
_spawn_remote_tracer(origin, target_or_dir) _spawn_remote_tracer(origin, target_or_dir)
@@ -983,6 +995,13 @@ func _process(delta: float) -> void:
visual.set_wall_side(synced_wall_side) visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing) visual.set_dancing(synced_is_dancing)
# Upper body follows the owner's synced camera pitch
if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x)
# Replayed one-shot actions (reload, grenade throw)
if visual.has_method("play_action") and synced_action_seq != _last_action_seq:
_last_action_seq = synced_action_seq
visual.play_action(synced_action)
# Check for weapon changes # Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""): if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path) visual.set_weapon(synced_weapon_path)
@@ -1002,6 +1021,18 @@ func _process(delta: float) -> void:
_remote_footstep_timer = 0.0 _remote_footstep_timer = 0.0
return return
# Local third-person model: aim pitch + reload one-shot (also broadcast
# to remote peers via the synced action counter)
var lvisual = get_visual_model()
if lvisual:
if lvisual.has_method("set_aim_pitch") and head_pivot:
lvisual.set_aim_pitch(head_pivot.rotation.x)
var lw = _active_weapon()
var now_reloading: bool = lw != null and "reloading" in lw and lw.reloading
if now_reloading and not _was_reloading:
_trigger_action("reload")
_was_reloading = now_reloading
# Update UI # Update UI
if is_instance_valid(health_bar): if is_instance_valid(health_bar):
health_bar.value = health health_bar.value = health
@@ -1311,9 +1342,29 @@ func _draw_trajectory() -> void:
current_pos = next_pos current_pos = next_pos
throw_vel = next_vel throw_vel = next_vel
## The local player's currently equipped weapon node (null if none).
func _active_weapon():
if not is_instance_valid(camera):
return null
var wman = camera.get_node_or_null("WeaponManager")
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
return wman.weapons[wman.active_slot]
return null
## Play a named one-shot on the local model and broadcast it to peers.
func _trigger_action(action: String) -> void:
synced_action = action
synced_action_seq += 1
var v = get_visual_model()
if v and v.has_method("play_action"):
v.play_action(action)
func _throw_grenade() -> void: func _throw_grenade() -> void:
if grenades <= 0: return if grenades <= 0: return
grenades -= 1 grenades -= 1
_trigger_action("throw")
var throw_speed = 20.0 var throw_speed = 20.0
var throw_up_speed = 5.0 var throw_up_speed = 5.0
+14
View File
@@ -34,6 +34,20 @@ static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment
fill.shadow_enabled = false fill.shadow_enabled = false
level.add_child(fill) level.add_child(fill)
# Screen-space ink edge pass: drawn outlines on every model for whatever
# camera renders (fullscreen POSITION-override quad, never culled).
if not level.has_node("InkEdgePost"):
var ink := MeshInstance3D.new()
ink.name = "InkEdgePost"
var quad := QuadMesh.new()
quad.size = Vector2(2, 2)
var ink_mat := ShaderMaterial.new()
ink_mat.shader = load("res://assets/shaders/ink_edge.gdshader")
quad.material = ink_mat
ink.mesh = quad
ink.extra_cull_margin = 16384.0
level.add_child(ink)
# Quiet ambient bed so the arena never sits in dead air. # Quiet ambient bed so the arena never sits in dead air.
if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"): if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"):
var amb := AudioStreamPlayer.new() var amb := AudioStreamPlayer.new()
+2
View File
@@ -134,6 +134,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_sp") client_rep_config.add_property(":synced_loadout_sp")
client_rep_config.add_property(":synced_loadout_melee") client_rep_config.add_property(":synced_loadout_melee")
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_action")
client_rep_config.add_property(":synced_action_seq")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
+237 -25
View File
@@ -96,6 +96,28 @@ func _kit_scene(key: String) -> PackedScene:
return _kit_cache[key] return _kit_cache[key]
# ── Hero assets (original kitbash, tools/build_hero_assets.py) ──────────────
## Hero piece with a box collider. Front faces -Z before yaw (glTF Y-up).
func _hero(key: String, pos: Vector3, yaw_deg: float, col_size: Vector3,
col_center_y: float, acoustic: String = "concrete") -> void:
var body := StaticBody3D.new()
body.name = "Hero_" + key + "_%d" % _rng.randi()
body.set_meta("acoustic_material", acoustic)
add_child(body)
body.global_position = pos
body.rotation_degrees.y = yaw_deg
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = col_size
shape.shape = bs
shape.position = Vector3(0, col_center_y, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene_at("res://assets/props/hero/" + key + ".glb").instantiate()
body.add_child(inst)
LevelMaterials.apply_toon_recursive(inst, 0.015)
## Small visual-only kit prop (parasols, awnings): no collider needed. ## Small visual-only kit prop (parasols, awnings): no collider needed.
func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void: func _kit_prop(key: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
var inst: Node3D = _kit_scene(key).instantiate() var inst: Node3D = _kit_scene(key).instantiate()
@@ -176,23 +198,45 @@ func _kit_fill_side(front_x: float, dir: int, z0: float, z1: float, seed_id: Str
var zc := cursor + w * 0.5 var zc := cursor + w * 0.5
var h: float = KIT_BUILDINGS[key].y * KIT_SCALE var h: float = KIT_BUILDINGS[key].y * KIT_SCALE
_kit_building(key, Vector3(front_x, 0, zc), yaw, "K%s_%d" % [seed_id, idx]) _kit_building(key, Vector3(front_x, 0, zc), yaw, "K%s_%d" % [seed_id, idx])
var face_yaw := -90.0 if dir > 0 else 90.0 # ad quads face the street
# Kitbashed konbini storefront module on wide buildings
if w >= 12.0 and _rng.randi() % 3 == 0:
_hero("konbini", Vector3(front_x - float(dir) * 0.75, 0.02, zc), 90.0 * float(dir),
Vector3(8.2, 4.2, 1.4), 2.1, "glass")
# Storefront awning on some other buildings (kit detail piece)
elif _rng.randi() % 5 < 2:
_kit_prop_path(KIT_DIR + "detail-awning-wide.glb",
Vector3(front_x, 3.1, zc), face_yaw, minf(w * 0.9, 11.0))
# Street-level posters plastered on the facade (graphic density)
var posters := 1 + _rng.randi() % 3
for p in posters:
var pz := cursor + _rng.randf_range(1.5, maxf(w - 1.5, 2.0))
_ad_quad(Vector3(front_x - float(dir) * 0.15, _rng.randf_range(1.7, 2.4), pz),
Vector2(1.3, 2.0) * _rng.randf_range(0.8, 1.1), face_yaw, _poster_tex())
# Protruding double-sided shop signs over the sidewalk (2nd floor)
if h >= 8.0:
var signs := 1 + _rng.randi() % 2
for sp in signs:
var sz := cursor + w * (float(sp) + 0.7) / (float(signs) + 0.7)
var spos := Vector3(front_x - float(dir) * 0.9, 5.6 + _rng.randf_range(-0.4, 0.6), sz)
var stx := _poster_tex()
_deco_box(spos + Vector3(float(dir) * 0.75, 0, 0), Vector3(1.6, 0.12, 0.12), METAL.darkened(0.2))
_ad_quad(spos + Vector3(0.06, 0, 0), Vector2(1.2, 1.7), 90.0, stx)
_ad_quad(spos + Vector3(-0.06, 0, 0), Vector2(1.2, 1.7), -90.0, stx)
# Akiba signage layer, aligned to the model's measured bounds # Akiba signage layer, aligned to the model's measured bounds
if h >= 14.0: if h >= 14.0:
var zs := cursor + 1.2 var zs := cursor + 1.2
var col_h := h * 0.55 var col_h := h * 0.55
_box_static(Vector3(front_x - float(dir) * 0.45, h * 0.35, zs), Vector3(0.7, col_h, 1.4), _box_static(Vector3(front_x - float(dir) * 0.45, h * 0.35, zs), Vector3(0.7, col_h, 1.4),
METAL, "K%s_%d_sc" % [seed_id, idx], "metal") METAL, "K%s_%d_sc" % [seed_id, idx], "metal")
var segs := maxi(2, int(col_h / 3.4)) _ad_quad(Vector3(front_x - float(dir) * 0.84, h * 0.35, zs),
for s2 in segs: Vector2(1.25, col_h * 0.9), face_yaw, _column_tex())
var sy := h * 0.35 - col_h * 0.5 + col_h * (float(s2) + 0.5) / float(segs)
_deco_box(Vector3(front_x - float(dir) * 0.85, sy, zs), Vector3(0.12, 2.2, 1.2),
SIGN_COLORS[(s2 + idx + seed_id.hash()) % SIGN_COLORS.size()], true)
if h >= 20.0: if h >= 20.0:
var bw := minf(w - 4.0, 10.0) var bw := minf(w - 4.0, 10.0)
_box_static(Vector3(front_x + float(dir) * 2.0, h + 1.9, zc), Vector3(0.5, 3.0, bw), _box_static(Vector3(front_x + float(dir) * 2.0, h + 1.9, zc), Vector3(0.5, 3.0, bw),
METAL, "K%s_%d_bf" % [seed_id, idx], "metal") METAL, "K%s_%d_bf" % [seed_id, idx], "metal")
_deco_box(Vector3(front_x + float(dir) * 1.7, h + 1.9, zc), Vector3(0.12, 2.4, bw - 0.6), _ad_quad(Vector3(front_x + float(dir) * 1.7, h + 1.9, zc),
SIGN_COLORS[(idx + seed_id.hash()) % SIGN_COLORS.size()], true) Vector2(bw - 0.6, 2.5), face_yaw, _board_tex())
cursor += w cursor += w
idx += 1 idx += 1
@@ -210,6 +254,46 @@ func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String =
return body return body
# ── Ad graphics (generated library: tools/generate_ads.py) ──────────────────
var _ad_mats: Dictionary = {}
func _ad_material(path: String) -> StandardMaterial3D:
if _ad_mats.has(path):
return _ad_mats[path]
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
var tex: Texture2D = load(path)
m.albedo_texture = tex
m.emission_enabled = true
m.emission_texture = tex
m.emission_energy_multiplier = 0.9
_ad_mats[path] = m
return m
## A lit ad panel (poster/billboard/sign face). Quad faces +Z before yaw.
func _ad_quad(pos: Vector3, size: Vector2, yaw_deg: float, tex_path: String) -> void:
var mi := MeshInstance3D.new()
var qm := QuadMesh.new()
qm.size = size
qm.material = _ad_material(tex_path)
mi.mesh = qm
add_child(mi)
mi.global_position = pos
mi.rotation_degrees.y = yaw_deg
func _poster_tex() -> String:
return "res://assets/textures/ads/ad_%02d.png" % (_rng.randi() % 10)
func _board_tex() -> String:
return "res://assets/textures/ads/board_%02d.png" % (_rng.randi() % 8)
func _column_tex() -> String:
return "res://assets/textures/ads/column_%02d.png" % (_rng.randi() % 6)
func _deco_box(pos: Vector3, size: Vector3, color: Color, emissive: bool = false) -> void: func _deco_box(pos: Vector3, size: Vector3, color: Color, emissive: bool = false) -> void:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var bm := BoxMesh.new() var bm := BoxMesh.new()
@@ -270,6 +354,7 @@ func _build_geometry() -> void:
_build_rail_line() _build_rail_line()
_build_streets() _build_streets()
_build_avenues() _build_avenues()
_build_parked_cars()
if _spawn_points.is_empty(): if _spawn_points.is_empty():
_spawn_points.append(Vector3(0, 2, 0)) _spawn_points.append(Vector3(0, 2, 0))
@@ -383,6 +468,17 @@ func _standard_block(c: Vector3, bx: int, bz: int) -> void:
var y := float(f) * FLOOR_H - 0.4 var y := float(f) * FLOOR_H - 0.4
_box_static(c + Vector3(-2.0 + float(f) * 1.3, y, 4.0), Vector3(2.2, 0.16, 2.2), METAL, _box_static(c + Vector3(-2.0 + float(f) * 1.3, y, 4.0), Vector3(2.2, 0.16, 2.2), METAL,
"Esc_%d_%d_%d" % [bx, bz, f], "metal") "Esc_%d_%d_%d" % [bx, bz, f], "metal")
# Sidewalk life: phone booth on some corners, bike rows by the fronts
if _rng.randi() % 4 == 0:
_hero("phonebooth", c + Vector3(-half - 0.6, 0.1, -half - 0.6), _rng.randf_range(0.0, 360.0),
Vector3(1.2, 2.7, 1.2), 1.35, "glass")
if _rng.randi() % 2 == 0:
var bikes := 2 + _rng.randi() % 3
var bside := 1.0 if _rng.randi() % 2 == 0 else -1.0
for b in bikes:
_kit_prop_path("res://assets/props/hero/bicycle.glb",
c + Vector3(bside * (half + 1.0), 0.1, -6.0 + float(b) * 1.5),
90.0 * bside + _rng.randf_range(-14.0, 14.0), 1.0)
if _rng.randi() % 3 == 0: if _rng.randi() % 3 == 0:
_spawn_points.append(c + Vector3(0, 1.5, 0)) _spawn_points.append(c + Vector3(0, 1.5, 0))
@@ -420,8 +516,12 @@ func _tower_block(c: Vector3, bx: int, bz: int) -> void:
# Rooftop crown billboard both directions (skyline wayfinding) # Rooftop crown billboard both directions (skyline wayfinding)
var tw := dims.x * s var tw := dims.x * s
_box_static(c + Vector3(0, h + 2.4, 0), Vector3(0.6, 4.0, tw - 4.0), METAL, n + "_Crown", "metal") _box_static(c + Vector3(0, h + 2.4, 0), Vector3(0.6, 4.0, tw - 4.0), METAL, n + "_Crown", "metal")
_deco_box(c + Vector3(0.45, h + 2.4, 0), Vector3(0.12, 3.4, tw - 4.6), SIGN_COLORS[(bx + bz) % SIGN_COLORS.size()], true) _ad_quad(c + Vector3(0.42, h + 2.4, 0), Vector2(tw - 4.6, 3.3), 90.0, _board_tex())
_deco_box(c + Vector3(-0.45, h + 2.4, 0), Vector3(0.12, 3.4, tw - 4.6), SIGN_COLORS[(bx + bz + 2) % SIGN_COLORS.size()], true) _ad_quad(c + Vector3(-0.42, h + 2.4, 0), Vector2(tw - 4.6, 3.3), -90.0, _board_tex())
# Giant mid-height wall ad on the tower's street faces (ZZZ-style)
var wall_off := dims.z * s * 0.5 + 0.15
_ad_quad(c + Vector3(0, FLOOR_H + dims.y * s * 0.45, -wall_off).rotated(Vector3.UP, deg_to_rad(yaw)),
Vector2(tw * 0.55, tw * 0.8), yaw + 180.0, _poster_tex())
_spawn_points.append(c + Vector3(0, FLOOR_H + 1.5, (BLOCK - 4.0) * 0.4)) _spawn_points.append(c + Vector3(0, FLOOR_H + 1.5, (BLOCK - 4.0) * 0.4))
@@ -438,23 +538,26 @@ func _shrine_block(c: Vector3, bx: int, bz: int) -> void:
_box_static(c + Vector3(0.0 if horiz else off, 0.7, off if horiz else 0.0), _box_static(c + Vector3(0.0 if horiz else off, 0.7, off if horiz else 0.0),
Vector3(BLOCK - 6.0 if horiz else 0.6, 1.4, 0.6 if horiz else BLOCK - 6.0), Vector3(BLOCK - 6.0 if horiz else 0.6, 1.4, 0.6 if horiz else BLOCK - 6.0),
Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick") Color(0.55, 0.55, 0.52), n + "_Wall%d" % i, "brick")
# Torii gate at the south entry # Kitbashed torii (curved kasagi) at the south entry; pillar colliders
var tz := c.z + (BLOCK - 6.0) * 0.5 var tz := c.z + (BLOCK - 6.0) * 0.5
_box_static(c + Vector3(-4.0, 3.5, tz), Vector3(0.8, 7.0, 0.8), VERMILION, n + "_PillarW", "wood") _hero("torii", c + Vector3(0, 0.1, tz), 0.0, Vector3(0.1, 0.1, 0.1), 0.05, "wood")
_box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.8, 7.0, 0.8), VERMILION, n + "_PillarE", "wood") _box_static(c + Vector3(-4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarW", "wood").visible = false
_box_static(c + Vector3(0, 7.0, tz), Vector3(11.0, 0.7, 1.1), VERMILION, n + "_BeamTop", "wood") _box_static(c + Vector3(4.0, 3.5, tz), Vector3(0.9, 7.0, 0.9), VERMILION, n + "_PillarE", "wood").visible = false
_box_static(c + Vector3(0, 5.8, tz), Vector3(9.4, 0.5, 0.9), VERMILION.darkened(0.15), n + "_BeamLow", "wood")
# Hall with slide roof # Hall with slide roof
_box_static(c + Vector3(0, 2.0, -10.0), Vector3(12.0, 4.0, 8.0), WOOD.darkened(0.2), n + "_Hall", "wood") _box_static(c + Vector3(0, 2.0, -10.0), Vector3(12.0, 4.0, 8.0), WOOD.darkened(0.2), n + "_Hall", "wood")
_ramp_static(c + Vector3(0, 4.9, -8.2), Vector3(13.0, 0.3, 5.6), Vector3(-24, 0, 0), VERMILION.darkened(0.35), n + "_Roof", "wood") _ramp_static(c + Vector3(0, 4.9, -8.2), Vector3(13.0, 0.3, 5.6), Vector3(-24, 0, 0), VERMILION.darkened(0.35), n + "_Roof", "wood")
# Stone lanterns + real modeled trees (trunk collider under each) # Stone + hanging lanterns, wooden gate fence, real modeled trees
for i in 2: for i in 2:
var lx := -8.0 + 16.0 * float(i) var lx := -8.0 + 16.0 * float(i)
_box_static(c + Vector3(lx, 1.0, 8.0), Vector3(1.0, 2.0, 1.0), Color(0.55, 0.55, 0.52), n + "_Lant%d" % i, "brick") _box_static(c + Vector3(lx, 1.0, 8.0), Vector3(1.0, 2.0, 1.0), Color(0.55, 0.55, 0.52), n + "_Lant%d" % i, "brick")
_kit_prop_path("res://assets/props/town/lantern.glb", c + Vector3(lx, 2.0, 8.0), 0.0, 2.6)
_box_static(c + Vector3(lx, 1.6, -2.0), Vector3(0.8, 3.2, 0.8), TRUNK, n + "_Trunk%d" % i, "wood") _box_static(c + Vector3(lx, 1.6, -2.0), Vector3(0.8, 3.2, 0.8), TRUNK, n + "_Trunk%d" % i, "wood")
_kit_prop_path("res://assets/props/nature/" + ["tree_detailed.glb", "tree_default.glb"][i % 2], _kit_prop_path("res://assets/props/nature/" + ["tree_detailed.glb", "tree_default.glb"][i % 2],
c + Vector3(lx, 0, -2.0), _rng.randf_range(0.0, 360.0), 7.0) c + Vector3(lx, 0, -2.0), _rng.randf_range(0.0, 360.0), 7.0)
_kit_prop_path("res://assets/props/nature/tree_oak.glb", c + Vector3(0, 0, 2.0), _rng.randf_range(0.0, 360.0), 6.0) _kit_prop_path("res://assets/props/nature/tree_oak.glb", c + Vector3(0, 0, 2.0), _rng.randf_range(0.0, 360.0), 6.0)
# Fence gate flanking the torii approach
_kit_prop_path("res://assets/props/town/fence.glb", c + Vector3(-8.0, 0.15, tz - 0.2), 0.0, 3.0)
_kit_prop_path("res://assets/props/town/fence.glb", c + Vector3(8.0, 0.15, tz - 0.2), 0.0, 3.0)
_spawn_points.append(c + Vector3(0, 1.5, 12)) _spawn_points.append(c + Vector3(0, 1.5, 12))
@@ -468,17 +571,27 @@ func _market_block(c: Vector3, bx: int, bz: int) -> void:
for pi in 3: for pi in 3:
var pp := c + Vector3(_rng.randf_range(-9.0, 9.0), 0.1, _rng.randf_range(-12.0, 12.0)) var pp := c + Vector3(_rng.randf_range(-9.0, 9.0), 0.1, _rng.randf_range(-12.0, 12.0))
_kit_prop(["detail-parasol-a", "detail-parasol-b"][pi % 2], pp, _rng.randf_range(0.0, 360.0), 6.0) _kit_prop(["detail-parasol-a", "detail-parasol-b"][pi % 2], pp, _rng.randf_range(0.0, 360.0), 6.0)
# Stall rows down the middle # Modeled stall rows down the middle (kit stalls + carts, box colliders)
var stall_keys := ["stall", "stall-green", "stall-red", "stall-bench"]
for r in 2: for r in 2:
var x := c.x + (-5.0 if r == 0 else 5.0) var x := c.x + (-5.0 if r == 0 else 5.0)
for s in 4: for s in 4:
var z := c.z - 15.0 + float(s) * 10.0 var z := c.z - 15.0 + float(s) * 10.0
_box_static(Vector3(x, 1.0, z), Vector3(3.2, 2.0, 2.2), WOOD, "%s_S%d_%d" % [n, r, s], "wood") var body := _box_static(Vector3(x, 1.3, z), Vector3(3.4, 2.6, 3.0), WOOD, "%s_S%d_%d" % [n, r, s], "wood")
_box_static(Vector3(x, 2.35, z), Vector3(4.0, 0.16, 3.0), for mi in body.find_children("*", "MeshInstance3D", false, false):
SIGN_COLORS[(r + s) % SIGN_COLORS.size()].darkened(0.5), "%s_C%d_%d" % [n, r, s], "wood") mi.visible = false # collider only; the kit stall is the visual
_kit_prop_path("res://assets/props/town/%s.glb" % stall_keys[(r * 4 + s + bx) % stall_keys.size()],
Vector3(x, 0.02, z), 90.0 if r == 0 else -90.0, 3.2)
if s % 2 == 0:
_kit_prop_path("res://assets/props/town/lantern.glb",
Vector3(x + 2.2, 0.02, z + 1.8), 0.0, 3.0)
# Lantern string over each row # Lantern string over each row
for l in 6: for l in 6:
_deco_box(Vector3(x, 3.6, c.z - 17.5 + float(l) * 7.0), Vector3(0.32, 0.45, 0.32), Color(1.0, 0.62, 0.25), true) _deco_box(Vector3(x, 3.6, c.z - 17.5 + float(l) * 7.0), Vector3(0.32, 0.45, 0.32), Color(1.0, 0.62, 0.25), true)
# Hand cart + kitbashed yatai ramen stand at the market mouths
_kit_prop_path("res://assets/props/town/cart.glb", c + Vector3(0, 0.02, 19.0), _rng.randf_range(-30, 30), 3.0)
_hero("yatai", c + Vector3(2.0, 0.02, -19.0), _rng.randf_range(150.0, 210.0),
Vector3(3.0, 2.8, 1.8), 1.4, "wood")
# Vending pair at the north entry # Vending pair at the north entry
for v in 2: for v in 2:
_box_static(c + Vector3(-1.5 + 3.0 * float(v), 1.0, -BLOCK * 0.5 + 3.0), Vector3(1.1, 2.0, 0.9), _box_static(c + Vector3(-1.5 + 3.0 * float(v), 1.0, -BLOCK * 0.5 + 3.0), Vector3(1.1, 2.0, 0.9),
@@ -495,16 +608,21 @@ func _plaza_block(c: Vector3, bx: int, bz: int) -> void:
var p := c + Vector3(cos(a), 0, sin(a)) * (BLOCK * 0.32) var p := c + Vector3(cos(a), 0, sin(a)) * (BLOCK * 0.32)
_box_static(Vector3(p.x, 0.55, p.z), Vector3(4.0, 1.1, 4.0), Color(0.5, 0.48, 0.52), "%s_Pl%d" % [n, i], "brick") _box_static(Vector3(p.x, 0.55, p.z), Vector3(4.0, 1.1, 4.0), Color(0.5, 0.48, 0.52), "%s_Pl%d" % [n, i], "brick")
_kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 1.0, p.z), float(i) * 90.0, 4.5) _kit_prop_path("res://assets/props/nature/tree_oak.glb", Vector3(p.x, 1.0, p.z), float(i) * 90.0, 4.5)
# Kitbashed clock tower on the south-west plaza cell
if bx == 4 and bz == 5:
_hero("clock", c + Vector3(0, 0.1, 0), 0.0, Vector3(3.2, 10.5, 3.2), 5.25, "concrete")
# Koban police box watching the central crossing
if bx == 5 and bz == 5:
_hero("koban", c + Vector3(-BLOCK * 0.5 + 4.0, 0.02, -BLOCK * 0.5 + 4.0), 225.0,
Vector3(3.7, 3.4, 3.5), 1.7, "concrete")
# The Crossing screen tower on the single centre-most plaza cell # The Crossing screen tower on the single centre-most plaza cell
if bx == 5 and bz == 4: if bx == 5 and bz == 4:
_box_static(c + Vector3(0, 9.0, 0), Vector3(6.0, 18.0, 6.0), METAL.darkened(0.2), n + "_ScreenTower", "metal") _box_static(c + Vector3(0, 9.0, 0), Vector3(6.0, 18.0, 6.0), METAL.darkened(0.2), n + "_ScreenTower", "metal")
for face in 4: for face in 4:
var a2 := TAU * float(face) / 4.0 var a2 := TAU * float(face) / 4.0
var off := Vector3(cos(a2), 0, sin(a2)) * 3.2 var off := Vector3(cos(a2), 0, sin(a2)) * 3.15
var flat := absf(off.x) > absf(off.z) _ad_quad(c + off + Vector3(0, 11.0, 0), Vector2(4.6, 6.0),
_deco_box(c + off + Vector3(0, 11.0, 0), rad_to_deg(a2) + 90.0, _board_tex())
Vector3(0.14 if flat else 4.6, 6.0, 4.6 if flat else 0.14),
SIGN_COLORS[face % SIGN_COLORS.size()], true)
_spawn_points.append(c + Vector3(BLOCK * 0.2, 1.5, -BLOCK * 0.2)) _spawn_points.append(c + Vector3(BLOCK * 0.2, 1.5, -BLOCK * 0.2))
@@ -555,12 +673,44 @@ func _build_rail_line() -> void:
var x := -CITY_HALF + PITCH * 0.5 var x := -CITY_HALF + PITCH * 0.5
while x <= CITY_HALF: while x <= CITY_HALF:
_box_static(Vector3(x, (RAIL_TOP - 1.0) * 0.5, z), Vector3(2.2, RAIL_TOP - 1.0, 5.4), BRICK, "Rail_Pier%d" % i, "brick") _box_static(Vector3(x, (RAIL_TOP - 1.0) * 0.5, z), Vector3(2.2, RAIL_TOP - 1.0, 5.4), BRICK, "Rail_Pier%d" % i, "brick")
# vending pair under alternating arches # vending pair + posters under alternating arches
if i % 2 == 0: if i % 2 == 0:
_box_static(Vector3(x + 4.0, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.85, 0.3, 0.3), "Rail_V%da" % i, "metal") _box_static(Vector3(x + 4.0, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.85, 0.3, 0.3), "Rail_V%da" % i, "metal")
_box_static(Vector3(x + 5.6, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.3, 0.55, 0.85), "Rail_V%db" % i, "metal") _box_static(Vector3(x + 5.6, 1.0, z + 1.8), Vector3(1.1, 2.0, 0.9), Color(0.3, 0.55, 0.85), "Rail_V%db" % i, "metal")
_ad_quad(Vector3(x + 1.35, 2.0, z), Vector2(1.2, 1.8), 90.0, _poster_tex())
_ad_quad(Vector3(x - 1.35, 2.0, z), Vector2(1.2, 1.8), -90.0, _poster_tex())
i += 1 i += 1
x += PITCH x += PITCH
# Kitbashed station entrance beside the central avenue crossing, with a
# platform strip (canopy + benches + signs) up on the deck above it
_hero("station", Vector3(ROAD_W * 0.5 + 8.0, 0.02, z + 7.5), 90.0,
Vector3(6.0, 3.6, 5.0), 1.8, "concrete")
_hero("platform", Vector3(10.0, RAIL_TOP, z), 0.0, Vector3(14.0, 2.8, 2.4), 1.4, "metal")
_hero("platform", Vector3(-24.0, RAIL_TOP, z), 0.0, Vector3(14.0, 2.8, 2.4), 1.4, "metal")
# Pedestrian footbridge over the central avenue (deck + stair colliders
# are walkable: a third crossing route between the viaduct and plaza)
var fb_z := z + PITCH
var fb := StaticBody3D.new()
fb.name = "Footbridge"
fb.set_meta("acoustic_material", "metal")
add_child(fb)
fb.global_position = Vector3(0, 0, fb_z) # span runs along x, across the avenue
var fb_shape := CollisionShape3D.new()
var fb_box := BoxShape3D.new()
fb_box.size = Vector3(18.0, 0.4, 3.0)
fb_shape.shape = fb_box
fb_shape.position = Vector3(0, 5.5, 0)
fb.add_child(fb_shape)
var fb_inst: Node3D = _kit_scene_at("res://assets/props/hero/footbridge.glb").instantiate()
fb.add_child(fb_inst)
LevelMaterials.apply_toon_recursive(fb_inst, 0.015)
# Walkable stair ramps at both ends (over the stair visuals)
for e in [-1.0, 1.0]:
var ang := rad_to_deg(atan2(5.5, 6.4))
_ramp_static(Vector3(e * (9.0 + 3.2), 2.65, fb_z), Vector3(2.8, 0.3, 8.6),
Vector3(0, 0, -ang * e), PAVING, "FootbridgeStair_%d" % int(e), "metal").visible = false
# Slide ramps to the deck at the two main-avenue crossings # Slide ramps to the deck at the two main-avenue crossings
for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]: for sx in [-PITCH * 0.5 - 4.0, PITCH * 0.5 + 4.0]:
var ang := rad_to_deg(atan2(RAIL_TOP, 16.0)) var ang := rad_to_deg(atan2(RAIL_TOP, 16.0))
@@ -655,6 +805,15 @@ func _build_avenues() -> void:
_box_static(Vector3(x, 3.4, z), Vector3(0.26, 6.8, 0.26), METAL, "LampP_%d_%d" % [int(z), int(side)], "metal") _box_static(Vector3(x, 3.4, z), Vector3(0.26, 6.8, 0.26), METAL, "LampP_%d_%d" % [int(z), int(side)], "metal")
_kit_prop_path("res://assets/props/roads/light-curved.glb", _kit_prop_path("res://assets/props/roads/light-curved.glb",
Vector3(x, 0, z), 90.0 if side > 0 else -90.0, lamp_scale) Vector3(x, 0, z), 90.0 if side > 0 else -90.0, lamp_scale)
# Traffic signal on the opposite diagonal corner of each crossing
_kit_prop_path("res://assets/props/roads/light-square-cross.glb",
Vector3(x, 0, z - side * (ROAD_W * 0.5 + 0.8)), 90.0 if side > 0 else -90.0, lamp_scale * 0.8)
# Bus stop shelters on alternating blocks along the avenue
if int(roundf(z / PITCH)) % 2 == 0:
_hero("busstop", Vector3(ROAD_W * 0.5 + 2.0, 0.02, z + 18.0), -90.0,
Vector3(3.8, 2.6, 1.6), 1.3, "metal")
_hero("busstop", Vector3(-ROAD_W * 0.5 - 2.0, 0.02, z - 18.0), 90.0,
Vector3(3.8, 2.6, 1.6), 1.3, "metal")
for x in lines: for x in lines:
if absf(x) < 1.0: if absf(x) < 1.0:
continue continue
@@ -665,6 +824,59 @@ func _build_avenues() -> void:
Vector3(x, 0, z2), 0.0 if side > 0 else 180.0, lamp_scale) Vector3(x, 0, z2), 0.0 if side > 0 else 180.0, lamp_scale)
# ── Parked cars (Kenney Car Kit) ─────────────────────────────────────────────
const CAR_KEYS := ["sedan", "sedan", "taxi", "van", "suv", "hatchback-sports",
"delivery", "sedan-sports", "police"]
const CAR_SCALE := 1.8
## A parked car: metal-tagged collider, kit body, wheels attached to the
## model's wheel sockets, slight per-car tint.
func _parked_car(pos: Vector3, yaw_deg: float) -> void:
var key: String = CAR_KEYS[_rng.randi() % CAR_KEYS.size()]
var body := StaticBody3D.new()
body.name = "Car_%d" % _rng.randi()
body.set_meta("acoustic_material", "metal")
add_child(body)
body.global_position = pos
body.rotation_degrees.y = yaw_deg + _rng.randf_range(-3.0, 3.0)
var shape := CollisionShape3D.new()
var bs := BoxShape3D.new()
bs.size = Vector3(2.8, 2.6, 5.0)
shape.shape = bs
shape.position = Vector3(0, 1.3, 0)
body.add_child(shape)
var inst: Node3D = _kit_scene_at("res://assets/props/cars/" + key + ".glb").instantiate()
inst.scale = Vector3.ONE * CAR_SCALE
inst.position.y = 0.55 # wheel contact at ground after scaling
body.add_child(inst)
var wheel_scene := _kit_scene_at("res://assets/props/cars/wheel-default.glb")
for n in inst.find_children("wheel*", "Node3D", true, false):
if n.get_child_count() == 0 and not (n is MeshInstance3D):
var wheel: Node3D = wheel_scene.instantiate()
n.add_child(wheel)
LevelMaterials.apply_toon_recursive(inst, 0.0)
# Darken under the map lighting (kit albedo clips) + per-car hue
_tint_recursive(inst, Color(0.62, 0.62, 0.66) * Color(1, 1, 1).lerp(Color.from_hsv(_rng.randf(), 0.6, 1.0), 0.35))
## Seeded parking lanes hugging the curbs of the N-S streets.
func _build_parked_cars() -> void:
for x in _street_lines():
for row in GRID:
_rng.seed = hash(Vector2i(int(x), row)) + 913
if _rng.randf() < 0.45:
continue
var z_base := (float(row) - float(GRID - 1) * 0.5) * PITCH
var cars := 1 + _rng.randi() % 3
for i in cars:
var side := 1.0 if _rng.randi() % 2 == 0 else -1.0
var cx := x + side * (ROAD_W * 0.5 - 1.8)
var cz := z_base + _rng.randf_range(-18.0, 18.0)
_parked_car(Vector3(cx, 0.02, cz), 0.0 if _rng.randi() % 2 == 0 else 180.0)
func _kit_prop_path(path: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void: func _kit_prop_path(path: String, pos: Vector3, yaw_deg: float, scale_f: float) -> void:
var inst: Node3D = _kit_scene_at(path).instantiate() var inst: Node3D = _kit_scene_at(path).instantiate()
inst.scale = Vector3.ONE * scale_f inst.scale = Vector3.ONE * scale_f
@@ -103,6 +103,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_sp") client_rep_config.add_property(":synced_loadout_sp")
client_rep_config.add_property(":synced_loadout_melee") client_rep_config.add_property(":synced_loadout_melee")
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_action")
client_rep_config.add_property(":synced_action_seq")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""Kitbash original hero assets for Akiba Crossing in headless Blender.
Builds detailed modular pieces from beveled primitives with flat cel
palette materials (the game's toonify keeps base colors), origin at
ground center, sized in meters:
konbini.glb - 8m convenience-store front: fascia, sign band, framed
windows, recessed door, awning, drink machine, planter
station.glb - viaduct station entrance: canopy, pillars, real stairs,
handrails, sign totem
clock.glb - plaza clock tower: tiered base, column, 4 faces, ring
torii.glb - proper torii with curved kasagi beam and plaque
Run: blender --background --python tools/build_hero_assets.py
Outputs to assets/props/hero/.
"""
import math
import os
import sys
import bpy
OUT = os.path.join(os.getcwd(), "assets", "props", "hero")
os.makedirs(OUT, exist_ok=True)
_mats = {}
def mat(name, rgba):
if name in _mats:
return _mats[name]
m = bpy.data.materials.new(name)
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*rgba, 1.0)
bsdf.inputs["Roughness"].default_value = 0.9
_mats[name] = m
return m
INK = mat("ink", (0.07, 0.065, 0.09))
WHITE = mat("white", (0.85, 0.84, 0.82))
CREAM = mat("cream", (0.78, 0.74, 0.66))
RED = mat("red", (0.8, 0.2, 0.16))
VERMILION = mat("vermilion", (0.82, 0.24, 0.14))
TEAL = mat("teal", (0.16, 0.65, 0.65))
GLASS = mat("glass", (0.45, 0.68, 0.75))
METAL = mat("metal", (0.4, 0.44, 0.5))
WOOD = mat("wood", (0.42, 0.28, 0.16))
YELLOW = mat("yellow", (0.95, 0.75, 0.2))
GRAY = mat("gray", (0.55, 0.55, 0.58))
def clear():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
def box(name, size, loc, material, bevel=0.02, rot=(0, 0, 0)):
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot)
o = bpy.context.active_object
o.name = name
o.scale = (size[0] / 2.0, size[1] / 2.0, size[2] / 2.0)
bpy.ops.object.transform_apply(scale=True)
if bevel > 0:
b = o.modifiers.new("bevel", "BEVEL")
b.width = bevel
b.segments = 2
b.limit_method = "ANGLE"
o.data.materials.append(material)
return o
def cyl(name, r, depth, loc, material, rot=(0, 0, 0), verts=24):
bpy.ops.mesh.primitive_cylinder_add(vertices=verts, radius=r, depth=depth,
location=loc, rotation=rot)
o = bpy.context.active_object
o.name = name
o.data.materials.append(material)
return o
def export(path):
bpy.ops.object.select_all(action="SELECT")
bpy.ops.export_scene.gltf(filepath=path, use_selection=True)
print("hero: exported", path)
# ── Konbini storefront (8 x 4.2 x 1.4, front faces +Y) ───────────────────────
def konbini():
clear()
W, H, D = 8.0, 4.2, 1.2
# back panel + side returns
box("back", (W, 0.15, H), (0, -D / 2, H / 2), CREAM)
for sx in (-1, 1):
box("side", (0.3, D, H), (sx * (W / 2 - 0.15), 0, H / 2), CREAM)
# fascia + colored sign band with ink trim
box("fascia", (W, D, 0.5), (0, 0, H - 0.25), WHITE)
box("signband", (W - 0.3, D + 0.06, 0.7), (0, 0, H - 0.85), TEAL, 0.03)
box("trim_top", (W, D + 0.1, 0.08), (0, 0, H - 0.5), INK, 0.01)
box("trim_bot", (W, D + 0.1, 0.08), (0, 0, H - 1.2), INK, 0.01)
# framed windows left+right of the recessed door
for sx in (-1, 1):
cx = sx * (W / 4 + 0.35)
box("winframe", (2.6, 0.18, 2.2), (cx, D / 2 - 0.2, 1.35), INK, 0.015)
box("winglass", (2.3, 0.1, 1.9), (cx, D / 2 - 0.16, 1.35), GLASS, 0.0)
box("sill", (2.7, 0.35, 0.12), (cx, D / 2 - 0.25, 0.28), GRAY)
# window ad strip
box("winad", (1.0, 0.06, 0.5), (cx - 0.5, D / 2 - 0.08, 1.9), RED, 0.01)
# recessed doorway
box("doorback", (1.8, 0.1, 2.4), (0, -0.1, 1.2), INK, 0.0)
box("doorglassL", (0.8, 0.08, 2.1), (-0.45, 0.05, 1.15), GLASS, 0.0)
box("doorglassR", (0.8, 0.08, 2.1), (0.45, 0.05, 1.15), GLASS, 0.0)
box("doorframe", (2.0, 0.12, 0.15), (0, D / 2 - 0.35, 2.42), INK, 0.01)
# awning over the door
box("awning", (2.6, 0.9, 0.1), (0, D / 2 + 0.35, 2.6), RED, 0.02,
rot=(math.radians(12), 0, 0))
# drink machine + planter dressing
box("vending", (0.9, 0.7, 1.9), (W / 2 - 1.0, D / 2 - 0.3, 0.95), RED, 0.03)
box("vendglass", (0.7, 0.06, 1.0), (W / 2 - 1.0, D / 2 + 0.06, 1.3), GLASS, 0.0)
box("planter", (1.4, 0.5, 0.45), (-W / 2 + 1.1, D / 2 + 0.1, 0.225), GRAY, 0.03)
box("hedge", (1.25, 0.4, 0.35), (-W / 2 + 1.1, D / 2 + 0.1, 0.62), mat("leaf", (0.3, 0.55, 0.32)), 0.05)
export(os.path.join(OUT, "konbini.glb"))
# ── Station entrance (6 x 5 x 3.6, opening faces +Y) ─────────────────────────
def station():
clear()
W, D, H = 6.0, 5.0, 3.6
# canopy + ink edge
box("canopy", (W, D, 0.25), (0, 0, H), WHITE, 0.04)
box("canopy_edge", (W + 0.15, D + 0.15, 0.1), (0, 0, H - 0.16), INK, 0.01)
# pillars
for sx in (-1, 1):
for sy in (-1, 1):
cyl("pillar", 0.14, H, (sx * (W / 2 - 0.3), sy * (D / 2 - 0.3), H / 2), METAL)
# side walls (half height)
for sx in (-1, 1):
box("wall", (0.25, D, 1.2), (sx * (W / 2 - 0.125), 0, 0.6), CREAM)
# stairs descending toward -Y (6 steps down 1.8m)
steps = 6
for i in range(steps):
z = -0.15 - i * 0.3
y = -D / 2 + 0.55 + i * 0.7
box("step", (W - 1.2, 0.7, 0.3), (0, y, z), GRAY, 0.01)
# handrails along the stairwell
for sx in (-1, 1):
box("rail", (0.08, D - 0.8, 0.08),
(sx * (W / 2 - 0.7), 0.0, 0.95), INK, 0.0,
rot=(math.radians(-18), 0, 0))
for i in range(4):
box("baluster", (0.06, 0.06, 0.9),
(sx * (W / 2 - 0.7), -D / 2 + 0.8 + i * 1.1, 0.45 - i * 0.28), INK, 0.0)
# sign totem
box("totem", (0.5, 0.35, 4.4), (W / 2 + 0.5, D / 2 - 0.2, 2.2), METAL, 0.03)
box("totem_sign", (0.42, 0.1, 1.6), (W / 2 + 0.5, D / 2 - 0.01, 3.4), YELLOW, 0.01)
export(os.path.join(OUT, "station.glb"))
# ── Plaza clock tower (10m) ──────────────────────────────────────────────────
def clock():
clear()
box("base1", (3.2, 3.2, 0.5), (0, 0, 0.25), GRAY, 0.04)
box("base2", (2.4, 2.4, 0.5), (0, 0, 0.75), WHITE, 0.04)
cyl("column", 0.5, 6.5, (0, 0, 4.25), CREAM, verts=12)
for i in range(3):
cyl("colring", 0.58, 0.18, (0, 0, 1.6 + i * 2.4), INK, verts=12)
# head
box("head", (1.9, 1.9, 1.9), (0, 0, 8.4), WHITE, 0.06)
box("head_trim", (2.1, 2.1, 0.2), (0, 0, 7.45), INK, 0.02)
for i in range(4):
a = math.pi / 2 * i
dx, dy = math.cos(a), math.sin(a)
cyl("face", 0.68, 0.06, (dx * 0.98, dy * 0.98, 8.4), INK,
rot=(0, math.pi / 2, a))
cyl("face_in", 0.6, 0.08, (dx * 0.99, dy * 0.99, 8.4), WHITE,
rot=(0, math.pi / 2, a))
box("hand_h", (0.08, 0.08, 0.4), (dx * 1.05, dy * 1.05, 8.55), INK, 0.0)
box("hand_m", (0.08, 0.08, 0.28), (dx * 1.05 + dy * 0.12, dy * 1.05 + dx * 0.12, 8.32), INK, 0.0)
# crown
box("crown", (1.4, 1.4, 0.3), (0, 0, 9.5), TEAL, 0.03)
cyl("spire", 0.08, 1.2, (0, 0, 10.2), INK, verts=8)
export(os.path.join(OUT, "clock.glb"))
# ── Torii with curved kasagi (10 wide x 8 tall) ──────────────────────────────
def torii():
clear()
for sx in (-1, 1):
cyl("pillar", 0.42, 7.0, (sx * 4.0, 0, 3.5), VERMILION, verts=16)
cyl("pillar_base", 0.55, 0.5, (sx * 4.0, 0, 0.25), INK, verts=16)
# curved kasagi: segmented arc of boxes
segs = 9
for i in range(segs):
t = i / (segs - 1) - 0.5 # -0.5..0.5
x = t * 10.0
z = 7.3 + (1.0 - (2 * t) ** 2) * 0.45 # gentle arch
rot = (0, -t * 0.28, 0)
box("kasagi", (10.0 / segs + 0.15, 1.0, 0.55), (x, 0, z), INK, 0.02, rot=rot)
box("shimaki", (10.0 / segs + 0.15, 0.8, 0.35), (x, 0, z - 0.45), VERMILION, 0.02, rot=rot)
# nuki (lower tie beam) + plaque
box("nuki", (9.4, 0.55, 0.55), (0, 0, 5.6), VERMILION, 0.03)
box("gakuzuka", (0.5, 0.3, 1.1), (0, 0, 6.5), VERMILION, 0.02)
box("plaque", (0.9, 0.18, 1.3), (0, 0.2, 6.5), mat("plaque", (0.9, 0.86, 0.75)), 0.02)
export(os.path.join(OUT, "torii.glb"))
konbini()
station()
clock()
torii()
print("hero: all assets built")
# ── Batch 2: street furniture ────────────────────────────────────────────────
def yatai():
"""Ramen cart: body, roof, noren curtain strips, stools, lantern."""
clear()
box("body", (2.6, 1.2, 1.1), (0, 0, 0.75), WOOD, 0.03)
box("counter", (2.8, 1.35, 0.08), (0, 0, 1.34), mat("counter", (0.62, 0.45, 0.28)), 0.02)
for sx in (-1, 1):
box("post", (0.08, 0.08, 1.3), (sx * 1.2, -0.45, 2.0), INK, 0.0)
cyl("wheel", 0.28, 0.1, (sx * 0.9, 0.62, 0.28), INK, rot=(math.radians(90), 0, 0))
box("roof", (3.0, 1.5, 0.12), (0, 0, 2.7), RED, 0.02)
box("roof_trim", (3.1, 1.6, 0.06), (0, 0, 2.62), INK, 0.0)
# noren curtain strips across the front
for i in range(5):
x = -1.1 + i * 0.55
box("noren", (0.42, 0.04, 0.6), (x, 0.72, 2.28), mat("noren", (0.92, 0.9, 0.85)), 0.0)
box("noren_bar", (2.7, 0.05, 0.05), (0, 0.72, 2.6), INK, 0.0)
for i, x in enumerate((-0.9, 0.0, 0.9)):
cyl("stool", 0.18, 0.5, (x, 1.05, 0.25), RED, verts=12)
cyl("lantern", 0.16, 0.42, (1.35, 0.7, 2.3), mat("lamp", (0.98, 0.62, 0.25)), verts=12)
export(os.path.join(OUT, "yatai.glb"))
def koban():
"""Police box: small building, sign board, red lamp."""
clear()
W, D, H = 3.2, 3.0, 3.2
box("body", (W, D, H), (0, 0, H / 2), CREAM, 0.03)
box("roof", (W + 0.5, D + 0.5, 0.3), (0, 0, H + 0.15), TEAL, 0.03)
box("door", (0.95, 0.1, 2.1), (-0.6, D / 2, 1.05), INK, 0.01)
box("window", (1.1, 0.1, 1.0), (0.7, D / 2, 1.7), GLASS, 0.01)
box("winframe", (1.25, 0.06, 1.15), (0.7, D / 2 - 0.02, 1.7), INK, 0.01)
box("sign", (1.6, 0.12, 0.5), (0, D / 2 + 0.1, 2.75), WHITE, 0.01)
cyl("lamp", 0.12, 0.24, (0, D / 2 + 0.2, 3.05), RED, verts=10)
export(os.path.join(OUT, "koban.glb"))
def phonebooth():
clear()
box("frame", (1.1, 1.1, 2.5), (0, 0, 1.25), mat("booth", (0.2, 0.45, 0.3)), 0.03)
for a in range(4):
ang = math.pi / 2 * a
dx, dy = math.cos(ang), math.sin(ang)
box("glass", (0.06 if a % 2 == 0 else 0.85, 0.85 if a % 2 == 0 else 0.06, 1.6),
(dx * 0.53, dy * 0.53, 1.35), GLASS, 0.0)
box("cap", (1.25, 1.25, 0.18), (0, 0, 2.6), INK, 0.02)
export(os.path.join(OUT, "phonebooth.glb"))
def busstop():
clear()
box("bench", (2.6, 0.5, 0.1), (0, 0.2, 0.55), WOOD, 0.02)
for sx in (-1, 1):
box("leg", (0.1, 0.45, 0.55), (sx * 1.1, 0.2, 0.27), INK, 0.0)
box("cpost", (0.1, 0.1, 2.4), (sx * 1.5, -0.25, 1.2), METAL, 0.0)
box("croof", (3.6, 1.3, 0.1), (0, 0.15, 2.45), GLASS, 0.02)
box("croof_trim", (3.7, 1.4, 0.05), (0, 0.15, 2.38), INK, 0.0)
box("signpole", (0.08, 0.08, 3.0), (2.1, -0.3, 1.5), METAL, 0.0)
cyl("signdisc", 0.35, 0.06, (2.1, -0.3, 2.85), YELLOW, rot=(math.radians(90), 0, 0), verts=16)
export(os.path.join(OUT, "busstop.glb"))
def bicycle():
clear()
for wy in (-0.55, 0.55):
bpy.ops.mesh.primitive_torus_add(major_radius=0.32, minor_radius=0.035,
location=(0, wy, 0.32),
rotation=(math.radians(90), 0, math.radians(90)))
o = bpy.context.active_object
o.data.materials.append(INK)
box("frame1", (0.05, 0.7, 0.05), (0, 0.1, 0.55), RED, 0.0, rot=(math.radians(25), 0, 0))
box("frame2", (0.05, 0.6, 0.05), (0, -0.2, 0.5), RED, 0.0, rot=(math.radians(-35), 0, 0))
box("bar", (0.4, 0.05, 0.05), (0, -0.52, 0.85), INK, 0.0)
box("seat", (0.14, 0.3, 0.06), (0, 0.28, 0.85), INK, 0.01)
box("basket", (0.32, 0.3, 0.25), (0, -0.72, 0.72), METAL, 0.01)
export(os.path.join(OUT, "bicycle.glb"))
yatai()
koban()
phonebooth()
busstop()
bicycle()
print("hero: batch 2 built")
# ── Batch 3: footbridge + station platform ───────────────────────────────────
def footbridge():
"""Pedestrian bridge: 18m deck, X-trusses, railings, stairs both ends."""
clear()
SPAN, DH = 18.0, 5.5
box("deck", (SPAN, 3.0, 0.35), (0, 0, DH), GRAY, 0.03)
box("deck_edge", (SPAN + 0.2, 3.2, 0.12), (0, 0, DH - 0.22), INK, 0.01)
# side trusses: X pattern of rotated bars + top chord
for sy in (-1, 1):
y = sy * 1.45
box("chord", (SPAN, 0.12, 0.12), (0, y, DH + 1.25), TEAL, 0.01)
n = 6
for i in range(n):
x = -SPAN / 2 + SPAN / n * (i + 0.5)
for ang in (0.6, -0.6):
box("truss", (0.09, 0.09, 1.55), (x, y, DH + 0.62), TEAL, 0.0,
rot=(0, ang, 0))
# rail mesh
box("rail", (SPAN, 0.05, 0.5), (0, y, DH + 0.55), mat("railmesh", (0.3, 0.32, 0.38)), 0.0)
# stairs at both ends, descending outward along the bridge axis
steps = 10
for end in (-1, 1):
for i in range(steps):
z = DH - 0.3 - i * (DH - 0.3) / steps
x = end * (SPAN / 2 + 0.5 + i * 0.62)
box("step", (0.62, 2.6, 0.22), (x, 0, z), GRAY, 0.01)
# stair rails
box("stairrail", (steps * 0.64, 0.08, 0.08),
(end * (SPAN / 2 + 0.4 + steps * 0.31), 1.25, DH * 0.55), INK, 0.0,
rot=(0, end * -0.68, 0))
export(os.path.join(OUT, "footbridge.glb"))
def platform():
"""Station platform strip for the viaduct deck: canopy, benches, signs."""
clear()
L = 14.0
for i in range(4):
x = -L / 2 + L / 4 * (i + 0.5)
cyl("post", 0.1, 2.6, (x, 0, 1.3), METAL)
box("proof", (L, 2.2, 0.12), (0, 0, 2.7), WHITE, 0.02)
box("proof_edge", (L + 0.2, 2.4, 0.06), (0, 0, 2.6), TEAL, 0.01)
for x in (-L / 4, L / 4):
box("bench", (1.8, 0.45, 0.08), (x, 0.4, 0.5), WOOD, 0.01)
for sx in (-1, 1):
box("bleg", (0.08, 0.4, 0.5), (x + sx * 0.75, 0.4, 0.25), INK, 0.0)
box("psign", (2.2, 0.1, 0.55), (0, 0.9, 2.15), mat("psign", (0.15, 0.35, 0.6)), 0.01)
export(os.path.join(OUT, "platform.glb"))
footbridge()
platform()
print("hero: batch 3 built")
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Generate stylized ad/poster/sign textures for Akiba Crossing.
Bold two-tone layouts, punchy shapes and fake-glyph text blocks (random
stroke clusters that read as kanji/kana from gameplay distance) — the
graphic-design density of an anime city street, generated as a reusable
library:
assets/textures/ads/ad_XX.png (vertical 256x512 posters/banners)
assets/textures/ads/board_XX.png (horizontal 512x256 billboards)
assets/textures/ads/column_XX.png (128x512 vertical sign columns)
Run: python tools/generate_ads.py
"""
import random
from pathlib import Path
from PIL import Image, ImageDraw
OUT = Path("assets/textures/ads")
OUT.mkdir(parents=True, exist_ok=True)
PALETTES = [
[(238, 51, 94), (255, 255, 255), (24, 24, 32)],
[(38, 206, 220), (24, 24, 40), (255, 255, 255)],
[(255, 196, 37), (30, 30, 34), (238, 51, 94)],
[(120, 90, 240), (255, 255, 255), (255, 196, 37)],
[(46, 230, 120), (18, 24, 24), (255, 255, 255)],
[(255, 255, 255), (238, 51, 94), (24, 24, 32)],
[(24, 28, 44), (38, 206, 220), (255, 196, 37)],
[(255, 120, 40), (255, 255, 255), (30, 30, 34)],
]
rng = random.Random(20260719)
def glyph(d: ImageDraw.ImageDraw, x, y, s, color):
"""A fake kanji-ish glyph: a few thick strokes in an s x s box."""
w = max(2, s // 7)
strokes = rng.randint(2, 4)
for _ in range(strokes):
kind = rng.randint(0, 2)
if kind == 0: # horizontal bar
yy = y + rng.randint(0, s - w)
d.rectangle([x, yy, x + s, yy + w], fill=color)
elif kind == 1: # vertical bar
xx = x + rng.randint(0, s - w)
d.rectangle([xx, y, xx + w, y + s], fill=color)
else: # box
m = rng.randint(s // 5, s // 3)
d.rectangle([x + m, y + m, x + s - m, y + s - m], outline=color, width=w)
def text_block(d, x, y, s, count, color, vertical=True):
for i in range(count):
if vertical:
glyph(d, x, y + i * int(s * 1.25), s, color)
else:
glyph(d, x + i * int(s * 1.25), y, s, color)
def background(d, w, h, pal):
d.rectangle([0, 0, w, h], fill=pal[0])
layout = rng.randint(0, 3)
if layout == 0: # diagonal split
d.polygon([(0, h), (w, h), (w, rng.randint(0, h // 2))], fill=pal[1])
elif layout == 1: # bottom band
d.rectangle([0, int(h * 0.72), w, h], fill=pal[1])
elif layout == 2: # big circle
r = int(min(w, h) * 0.42)
cx, cy = rng.randint(r, w - r), rng.randint(r, h - r)
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=pal[1])
else: # stripes
n = rng.choice([3, 4])
for i in range(n):
if i % 2 == 0:
d.rectangle([i * w // n, 0, (i + 1) * w // n, h], fill=pal[1])
def poster(path, w, h):
pal = rng.choice(PALETTES)
img = Image.new("RGB", (w, h))
d = ImageDraw.Draw(img)
background(d, w, h, pal)
ink = pal[2]
if h > w: # vertical: glyph column + accent dot
s = w // 4
text_block(d, w - s - s // 2, s // 2, s, rng.randint(3, 5), ink, vertical=True)
if rng.random() < 0.6:
r = w // 6
d.ellipse([s // 2, h - 2 * r - s // 2, s // 2 + 2 * r, h - s // 2], fill=ink)
else: # horizontal: glyph row
s = h // 3
text_block(d, s // 2, (h - s) // 2, s, rng.randint(3, 6), ink, vertical=False)
# border
d.rectangle([0, 0, w - 1, h - 1], outline=(15, 14, 20), width=max(3, w // 60))
img.save(path)
def column(path, w, h):
"""Vertical sign column: stacked glyphs, one per segment."""
pal = rng.choice(PALETTES)
img = Image.new("RGB", (w, h))
d = ImageDraw.Draw(img)
d.rectangle([0, 0, w, h], fill=pal[0])
s = int(w * 0.6)
n = h // int(s * 1.4)
for i in range(n):
glyph(d, (w - s) // 2, int(i * s * 1.4 + s * 0.25), s, pal[2])
d.rectangle([0, 0, w - 1, h - 1], outline=(15, 14, 20), width=3)
img.save(path)
def main():
for i in range(10):
poster(OUT / f"ad_{i:02d}.png", 256, 512)
for i in range(8):
poster(OUT / f"board_{i:02d}.png", 512, 256)
for i in range(6):
column(OUT / f"column_{i:02d}.png", 128, 512)
print(f"generated {10 + 8 + 6} ad textures in {OUT}")
if __name__ == "__main__":
main()
+1
View File
@@ -66,6 +66,7 @@ LIBRARY_CLIP_MAP = {
"Pistol_Idle_Loop": "PistolIdle", # armed idle (weapon actually held up) "Pistol_Idle_Loop": "PistolIdle", # armed idle (weapon actually held up)
"Pistol_Shoot": "PistolShoot", "Pistol_Shoot": "PistolShoot",
"Pistol_Reload": "PistolReload", "Pistol_Reload": "PistolReload",
"Sword_Attack": "Throw", # overhead swing reads as a grenade throw
} }
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Strip an existing rig from a character GLB so tools/autorig.py re-rigs it on
the animation-library skeleton.
Some downloaded characters ship pre-rigged (VRoid J_Bip_*, Mixamo, custom
names). tools/merge_animations.py retargets by bone NAME from the library
armature, so a foreign skeleton silently produces flat 2-key clips — a
rest-pose statue (see docs/ASSET_SOURCES.md). The fix is to discard the
foreign rig entirely and let autorig fit the library's DEF-* skeleton.
The mesh keeps its REST pose: armature modifiers are removed (not applied),
so whatever pose the file rests in (usually T/A-pose) is what autorig sees.
Usage:
blender --background --python tools/strip_rig.py -- <input.glb> <output.glb>
"""
import bpy
import sys
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 2:
print("Usage: blender --background --python tools/strip_rig.py -- <in.glb> <out.glb>")
sys.exit(1)
INPUT, OUTPUT = argv[0], argv[1]
bpy.ops.wm.read_factory_settings(use_empty=True)
if INPUT.lower().endswith((".glb", ".gltf")):
bpy.ops.import_scene.gltf(filepath=INPUT)
elif INPUT.lower().endswith(".fbx"):
bpy.ops.import_scene.fbx(filepath=INPUT)
else:
print(f"ERROR: unsupported input {INPUT}")
sys.exit(1)
all_meshes = [o for o in bpy.data.objects if o.type == "MESH"]
if not all_meshes:
print("ERROR: no meshes in input")
sys.exit(1)
def is_skinned(obj) -> bool:
if any(m.type == "ARMATURE" for m in obj.modifiers):
return True
return obj.parent is not None and obj.parent.type == "ARMATURE"
# The CHARACTER is whatever was skinned to the rig. Anything else is scene
# dressing (bases, dioramas, floating props) that must not become part of
# the player model.
meshes = [o for o in all_meshes if is_skinned(o)]
if not meshes:
print("WARNING: no skinned meshes found — keeping all meshes")
meshes = all_meshes
else:
for obj in [o for o in all_meshes if o not in meshes]:
print(f"Dropping unskinned prop mesh: {obj.name}")
bpy.data.objects.remove(obj, do_unlink=True)
# Drop armature modifiers and parenting, keep the rest-pose mesh data.
for obj in meshes:
for mod in [m for m in obj.modifiers if m.type == "ARMATURE"]:
obj.modifiers.remove(mod)
if obj.parent and obj.parent.type == "ARMATURE":
world = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = world
# Old vertex groups reference the dead rig; autorig makes fresh ones.
obj.vertex_groups.clear()
for obj in [o for o in bpy.data.objects if o.type == "ARMATURE"]:
bpy.data.objects.remove(obj, do_unlink=True)
# Join into one mesh so autorig binds everything (hair/clothes included).
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
if len(meshes) > 1:
bpy.ops.object.join()
# Recentre: feet on the ground at the world origin (imports sometimes place
# the character far off-origin inside a larger scene).
joined = bpy.context.view_layer.objects.active
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
xs = [v.co.x for v in joined.data.vertices]
ys = [v.co.y for v in joined.data.vertices]
zs = [v.co.z for v in joined.data.vertices]
cx = (min(xs) + max(xs)) / 2.0
cy = (min(ys) + max(ys)) / 2.0
floor = min(zs) # Blender is Z-up; glTF exporter converts to Y-up
for v in joined.data.vertices:
v.co.x -= cx
v.co.y -= cy
v.co.z -= floor
# Normalize materials: some anime models ship UNLIT (black base color, albedo
# in the emissive texture). Our pipeline shades with the toon shader off the
# ALBEDO, so rewire each material's first image texture into Principled Base
# Color and drop the emission trick — else the character renders pitch black.
for mat in joined.data.materials:
if not mat or not mat.use_nodes:
continue
nt = mat.node_tree
tex_node = next((n for n in nt.nodes if n.type == "TEX_IMAGE" and n.image), None)
principled = next((n for n in nt.nodes if n.type == "BSDF_PRINCIPLED"), None)
if principled is None:
principled = nt.nodes.new("ShaderNodeBsdfPrincipled")
out = next((n for n in nt.nodes if n.type == "OUTPUT_MATERIAL"), None)
if out is None:
out = nt.nodes.new("ShaderNodeOutputMaterial")
for l in list(out.inputs["Surface"].links):
nt.links.remove(l)
nt.links.new(principled.outputs["BSDF"], out.inputs["Surface"])
if tex_node:
base = principled.inputs["Base Color"]
for l in list(base.links):
nt.links.remove(l)
nt.links.new(tex_node.outputs["Color"], base)
print(f"Material '{mat.name}': routed texture '{tex_node.image.name}' to base color")
# Kill the emission trick so the toon shader owns the look.
if "Emission Strength" in principled.inputs:
principled.inputs["Emission Strength"].default_value = 0.0
bpy.ops.export_scene.gltf(filepath=OUTPUT, export_format="GLB", export_skins=False,
export_animations=False, export_apply=True)
print(f"Done. Stripped rig -> {OUTPUT}")
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
Convert KHR_materials_unlit "emissive albedo" materials in a GLB to plain PBR.
Anime-style models often ship unlit: black baseColorFactor with the real
albedo in emissiveTexture. Blender's importer turns those into textureless
EMISSION node trees (the image is dropped), so everything downstream renders
pitch black. Rewriting the material JSON up front — baseColorTexture :=
emissiveTexture, white base factor, unlit/emissive stripped — gives every
tool in the pipeline a normal textured PBR model.
Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>
"""
import json
import struct
import sys
def main() -> None:
if len(sys.argv) < 3:
print("Usage: python tools/unlit_to_pbr.py <in.glb> <out.glb>")
sys.exit(1)
src, dst = sys.argv[1], sys.argv[2]
with open(src, "rb") as f:
data = f.read()
magic, version, _length = struct.unpack_from("<III", data, 0)
if magic != 0x46546C67:
print("ERROR: not a GLB file")
sys.exit(1)
offset = 12
json_chunk = None
other_chunks = []
while offset < len(data):
clen, ctype = struct.unpack_from("<II", data, offset)
chunk = data[offset + 8:offset + 8 + clen]
if ctype == 0x4E4F534A: # 'JSON'
json_chunk = chunk
else:
other_chunks.append((ctype, chunk))
offset += 8 + clen
doc = json.loads(json_chunk)
fixed = 0
for mat in doc.get("materials", []):
emis_tex = mat.get("emissiveTexture")
if emis_tex is None:
continue
pbr = mat.setdefault("pbrMetallicRoughness", {})
if "baseColorTexture" not in pbr:
pbr["baseColorTexture"] = emis_tex
pbr["baseColorFactor"] = [1.0, 1.0, 1.0, 1.0]
pbr.setdefault("metallicFactor", 0.0)
pbr["roughnessFactor"] = 1.0
mat.pop("emissiveTexture", None)
mat.pop("emissiveFactor", None)
exts = mat.get("extensions", {})
exts.pop("KHR_materials_unlit", None)
if not exts:
mat.pop("extensions", None)
fixed += 1
used = doc.get("extensionsUsed", [])
if "KHR_materials_unlit" in used:
used.remove("KHR_materials_unlit")
if not used:
doc.pop("extensionsUsed", None)
payload = json.dumps(doc, separators=(",", ":")).encode("utf-8")
payload += b" " * (-len(payload) % 4)
out = bytearray()
out += struct.pack("<II", len(payload), 0x4E4F534A) + payload
for ctype, chunk in other_chunks:
chunk = chunk + b"\x00" * (-len(chunk) % 4)
out += struct.pack("<II", len(chunk), ctype) + chunk
header = struct.pack("<III", 0x46546C67, version, 12 + len(out))
with open(dst, "wb") as f:
f.write(header + out)
print(f"Rewrote {fixed} unlit material(s) -> {dst}")
if __name__ == "__main__":
main()
+60 -38
View File
@@ -67,6 +67,7 @@ var _recoil_yaw: float = 0.0
var _target_drift_offset: Vector3 = Vector3.ZERO var _target_drift_offset: Vector3 = Vector3.ZERO
var _current_drift_offset: Vector3 = Vector3.ZERO var _current_drift_offset: Vector3 = Vector3.ZERO
var _swap_pitch: float = 0.0 var _swap_pitch: float = 0.0
var _reload_dip: float = 0.0
func add_recoil(pitch: float, yaw: float) -> void: func add_recoil(pitch: float, yaw: float) -> void:
_recoil_pitch += pitch * 20.0 # Scale up for visual model recoil _recoil_pitch += pitch * 20.0 # Scale up for visual model recoil
@@ -152,6 +153,20 @@ func _process(_delta: float) -> void:
vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_swap_pitch)) vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_swap_pitch))
vm_camera.translate_object_local(Vector3(0, -_swap_pitch * 0.005, 0)) vm_camera.translate_object_local(Vector3(0, -_swap_pitch * 0.005, 0))
# Reload animation: the weapon dips down-and-inward with a roll,
# holds through the reload, and rises as it completes.
var aw = weapons.get(active_slot)
if aw and "reloading" in aw and aw.reloading \
and "reload_timer" in aw and "reload_time" in aw and aw.reload_time > 0.0:
var t: float = clampf(1.0 - aw.reload_timer / aw.reload_time, 0.0, 1.0)
_reload_dip = lerpf(_reload_dip, sin(minf(t * 1.4, 1.0) * PI), 10.0 * _delta)
else:
_reload_dip = lerpf(_reload_dip, 0.0, 10.0 * _delta)
if _reload_dip > 0.01:
vm_camera.translate_object_local(Vector3(0.03 * _reload_dip, 0.14 * _reload_dip, 0))
vm_camera.rotate_object_local(Vector3.FORWARD, deg_to_rad(-14.0 * _reload_dip))
vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(9.0 * _reload_dip))
vm_camera.fov = lerpf(vm_camera.fov, target_fov, 10.0 * _delta) vm_camera.fov = lerpf(vm_camera.fov, target_fov, 10.0 * _delta)
func _build_loadout() -> void: func _build_loadout() -> void:
@@ -258,47 +273,54 @@ func _set_layer_recursive(node: Node, layer_mask: int) -> void:
func _add_procedural_arms(weapon: Node3D) -> void: func _add_procedural_arms(weapon: Node3D) -> void:
# Attach to weapon instead of model_root to avoid arms spinning on reload # Attach to weapon instead of model_root to avoid arms spinning on reload
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05))
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.4, 0.8) # Player color
mat.roughness = 0.8
# Right Arm (Main Grip)
var r_arm = MeshInstance3D.new()
var r_mesh = BoxMesh.new()
r_mesh.size = Vector3(0.08, 0.7, 0.08)
r_mesh.material = mat
r_arm.mesh = r_mesh
var r_pivot = Node3D.new()
var r_shoulder = Vector3(0.25, -0.3, 0.5)
var r_hand = Vector3(0.04, -0.05, 0.05)
r_pivot.position = r_shoulder
r_pivot.look_at_from_position(r_shoulder, r_hand, Vector3.UP)
r_arm.rotation_degrees.x = 90 # Mesh points UP by default, we need it to point forward along -Z
r_arm.position.z = -0.35
r_pivot.add_child(r_arm)
weapon.add_child(r_pivot)
# Left Arm (Foregrip)
if "weapon_name" in weapon and weapon.weapon_name != "Knife": if "weapon_name" in weapon and weapon.weapon_name != "Knife":
var l_arm = MeshInstance3D.new() _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3))
var l_mesh = BoxMesh.new()
l_mesh.size = Vector3(0.08, 0.7, 0.08)
l_mesh.material = mat
l_arm.mesh = l_mesh
var l_pivot = Node3D.new()
var l_shoulder = Vector3(-0.25, -0.3, 0.4)
var l_hand = Vector3(-0.02, -0.02, -0.3)
l_pivot.position = l_shoulder
l_pivot.look_at_from_position(l_shoulder, l_hand, Vector3.UP)
l_arm.rotation_degrees.x = 90 ## A first-person arm styled after the character skin: dark detached sleeve,
l_arm.position.z = -0.35 ## glowing cuff, bare hand — instead of the old featureless blue slab.
l_pivot.add_child(l_arm) func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void:
weapon.add_child(l_pivot) var sleeve_mat = StandardMaterial3D.new()
sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve
sleeve_mat.roughness = 0.8
var cuff_mat = StandardMaterial3D.new()
cuff_mat.albedo_color = Color(0.25, 0.95, 0.90) # signature teal cuff
cuff_mat.emission_enabled = true
cuff_mat.emission = Color(0.10, 0.55, 0.52)
var skin_mat = StandardMaterial3D.new()
skin_mat.albedo_color = Color(0.98, 0.88, 0.82) # skin
skin_mat.roughness = 0.9
var pivot = Node3D.new()
pivot.position = shoulder
pivot.look_at_from_position(shoulder, hand, Vector3.UP)
var sleeve = MeshInstance3D.new()
var sleeve_mesh = BoxMesh.new()
sleeve_mesh.size = Vector3(0.075, 0.075, 0.46)
sleeve_mesh.material = sleeve_mat
sleeve.mesh = sleeve_mesh
sleeve.position.z = -0.27
pivot.add_child(sleeve)
var cuff = MeshInstance3D.new()
var cuff_mesh = BoxMesh.new()
cuff_mesh.size = Vector3(0.085, 0.085, 0.05)
cuff_mesh.material = cuff_mat
cuff.mesh = cuff_mesh
cuff.position.z = -0.52
pivot.add_child(cuff)
var hand_box = MeshInstance3D.new()
var hand_mesh = BoxMesh.new()
hand_mesh.size = Vector3(0.06, 0.055, 0.12)
hand_mesh.material = skin_mat
hand_box.mesh = hand_mesh
hand_box.position.z = -0.60
pivot.add_child(hand_box)
weapon.add_child(pivot)
func _equip_slot(slot: int) -> void: func _equip_slot(slot: int) -> void:
if weapons.has(active_slot): if weapons.has(active_slot):