Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c746d084 | ||
|
|
6c227b5538 | ||
|
|
39af109893 | ||
|
|
7920aecec5 | ||
|
|
5224a455a6 | ||
|
|
911066875b | ||
|
|
18f50adb78 | ||
|
|
7f9a579b63 | ||
|
|
6b3e363dfd | ||
|
|
c8a85586bd | ||
|
|
648872a16d | ||
|
|
56db9eea59 | ||
|
|
04d91dea31 |
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 384 KiB |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dwst4d2srljqj
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dc7dqp671fvya
|
||||||
@@ -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 +0,0 @@
|
|||||||
uid://dd4g4l7mg66ro
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||