Files
open-wc/src/scenes/sky/wow_sky_controller.gd
T

939 lines
33 KiB
GDScript

## DBC-driven outdoor sky controller for WoW 3.3.5a data.
extends Node
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
const LIGHT_COORD_SCALE := 36.0
const HALF_MINUTES_PER_DAY := 2880
const CH_AMBIENT := 0
const CH_DIFFUSE := 1
const CH_SKY_TOP := 2
const CH_SKY_MIDDLE := 3
const CH_SKY_BAND_1 := 4
const CH_SKY_BAND_2 := 5
const CH_FOG := 6
const FOG_END := 0
const FOG_START_SCALAR := 1
const CLOUD_DENSITY := 2
const FOG_DENSITY := 3
const AREA_LIGHT_PARAMS_BY_ZONE := {
# Eastern Kingdoms outdoor fallback profiles for places not covered by Light.dbc volumes.
1: 28, # Dun Morogh
3: 40, # Badlands
4: 40, # Blasted Lands
8: 40, # Swamp of Sorrows
10: 92, # Duskwood
11: 34, # Wetlands
12: 28, # Elwynn Forest
28: 92, # Western Plaguelands
33: 118, # Stranglethorn Vale
36: 34, # Alterac Mountains
38: 28, # Loch Modan
40: 28, # Westfall
41: 92, # Deadwind Pass
44: 28, # Redridge Mountains
45: 28, # Arathi Highlands
46: 40, # Burning Steppes
47: 118, # Hinterlands
51: 40, # Searing Gorge
85: 92, # Tirisfal Glades
130: 92, # Silverpine Forest
139: 92, # Eastern Plaguelands
267: 28, # Hillsbrad Foothills
1519: 28, # Stormwind
1537: 28, # Ironforge
1497: 92, # Undercity
2365: 12, # Great Sea
}
@export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth"
@export var map_id: int = 0
@export var target_path: NodePath
@export var world_environment_path: NodePath
@export var sun_path: NodePath
@export var update_interval: float = 0.2
@export var use_system_time: bool = false
@export_range(0.0, 24.0, 0.1) var fixed_time_hours: float = 13.0
@export var time_speed: float = 0.0
@export var smooth_speed: float = 6.0
@export var debug_log_enabled: bool = true
@export var debug_log_interval: float = 5.0
@export var skybox_models_enabled: bool = true
@export var skybox_model_scale: float = 1.0
@export var skybox_model_height_offset: float = 0.0
## Non-zero forces a LightSkybox.dbc ID for visual testing on maps that do not use outdoor skybox models.
@export var debug_force_skybox_id: int = 0
var _world_environment: WorldEnvironment
var _environment: Environment
var _sky_material: ProceduralSkyMaterial
var _sun: DirectionalLight3D
var _target: Node3D
var _light_volumes_by_map: Dictionary = {}
var _default_light_params_by_map: Dictionary = {}
var _profiles: Dictionary = {}
var _light_skyboxes: Dictionary = {}
var _missing_skyboxes: Dictionary = {}
var _area_table: Dictionary = {}
var _adt_area_cache: Dictionary = {}
var _loaded := false
var _elapsed := 0.0
var _debug_elapsed := 0.0
var _current: Dictionary = {}
var _skybox_root: Node3D
var _active_skybox_id := -1
var _active_skybox_node: Node3D
var _last_logged_area_id := -1
var _last_logged_zone_id := -1
var _last_logged_profile_signature := ""
var _last_global_light_signature := ""
func _ready() -> void:
_resolve_nodes()
_prepare_environment()
_ensure_wow_shader_globals()
_loaded = _load_lighting_dbcs()
if _loaded:
print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [
_profiles.size(),
_area_table.size(),
_get_light_volume_count(map_id),
map_name])
else:
push_warning("WowSkyController: DBC lighting not loaded, using fallback sky")
_apply_sky(1.0)
_update_skybox_model()
_update_skybox_transform()
func _process(delta: float) -> void:
if not _environment or not _sky_material:
return
_elapsed += delta
_debug_elapsed += delta
fixed_time_hours = fposmod(fixed_time_hours + delta * time_speed, 24.0)
if _elapsed < update_interval:
_apply_sky(delta)
_update_skybox_transform()
return
_elapsed = 0.0
_sample_current_params()
_apply_sky(delta)
_update_skybox_model()
_update_skybox_transform()
## Accepts an authoritative time snapshot without moving DBC visual selection out
## of this controller. Existing fixed-clock behavior is preserved and frozen until
## another snapshot or explicit local clock configuration is supplied.
func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
if (
world_environment_snapshot == null
or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT
or not bool(world_environment_snapshot.get("is_valid"))
):
return false
use_system_time = false
time_speed = 0.0
fixed_time_hours = float(world_environment_snapshot.get("time_of_day_hours"))
return true
func _resolve_nodes() -> void:
if not world_environment_path.is_empty():
_world_environment = get_node_or_null(world_environment_path) as WorldEnvironment
else:
_world_environment = get_parent().get_node_or_null("WorldEnvironment") as WorldEnvironment
if not sun_path.is_empty():
_sun = get_node_or_null(sun_path) as DirectionalLight3D
else:
_sun = get_parent().get_node_or_null("Sun") as DirectionalLight3D
if not target_path.is_empty():
_target = get_node_or_null(target_path) as Node3D
_skybox_root = Node3D.new()
_skybox_root.name = "SkyboxModelRoot"
_skybox_root.top_level = true
add_child(_skybox_root)
func _prepare_environment() -> void:
if not _world_environment:
push_warning("WowSkyController: WorldEnvironment not found")
return
_environment = _world_environment.environment
if not _environment:
_environment = Environment.new()
else:
_environment = _environment.duplicate(true)
_world_environment.environment = _environment
_environment.background_mode = Environment.BG_SKY
_environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
_environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
_environment.fog_enabled = true
_environment.fog_mode = Environment.FOG_MODE_EXPONENTIAL
var sky := _environment.sky
if not sky:
sky = Sky.new()
_environment.sky = sky
_sky_material = sky.sky_material as ProceduralSkyMaterial
if not _sky_material:
_sky_material = ProceduralSkyMaterial.new()
sky.sky_material = _sky_material
_sky_material.use_debanding = true
sky.radiance_size = Sky.RADIANCE_SIZE_256
func _sample_current_params() -> void:
var time_hours := _get_time_hours()
var time_half := int(floor(fposmod(time_hours, 24.0) * 120.0)) % HALF_MINUTES_PER_DAY
var wow_pos := _get_target_wow_position()
var area_id := _get_target_area_id()
var zone_id := _get_zone_area_id(area_id)
var selected := _select_light_params(wow_pos, area_id, zone_id)
var params := _sample_blended_params(selected, time_half)
params["time_hours"] = time_hours
params["light_params"] = selected
params["skybox_id"] = _select_skybox_id(selected)
params["area_id"] = area_id
params["zone_id"] = zone_id
_current = params
var profile_signature := str(selected)
if debug_log_enabled and (
_debug_elapsed >= debug_log_interval
or area_id != _last_logged_area_id
or zone_id != _last_logged_zone_id
or profile_signature != _last_logged_profile_signature
):
_debug_elapsed = 0.0
_last_logged_area_id = area_id
_last_logged_zone_id = zone_id
_last_logged_profile_signature = profile_signature
print("SKY_LIGHT time=%.2f map=%d area=%d:%s zone=%d:%s wow=(%.1f,%.1f,%.1f) profiles=%s skybox=%s fog=%.0f..%.0f density=%.3f" % [
time_hours,
map_id,
area_id,
_get_area_name(area_id),
zone_id,
_get_area_name(zone_id),
wow_pos.x,
wow_pos.y,
wow_pos.z,
str(selected),
str(params["skybox_id"]),
float(params.get("fog_start", 0.0)),
float(params.get("fog_end", 0.0)),
float(params.get("fog_density", 0.0))])
func _apply_sky(delta: float) -> void:
if _current.is_empty():
_sample_current_params()
var blend := 1.0
if delta > 0.0 and smooth_speed > 0.0:
blend = clamp(delta * smooth_speed, 0.0, 1.0)
var sky_top: Color = _current.get("sky_top", Color(0.13, 0.32, 0.62))
var sky_mid: Color = _current.get("sky_middle", Color(0.36, 0.58, 0.86))
var sky_horizon: Color = _current.get("sky_band_1", Color(0.62, 0.76, 0.9))
var sky_low: Color = _current.get("sky_band_2", sky_horizon)
var fog_color: Color = _current.get("fog_color", Color(0.55, 0.66, 0.72))
var ambient: Color = _current.get("ambient", Color(0.72, 0.8, 0.88))
var diffuse: Color = _current.get("diffuse", Color(1.0, 0.91, 0.78))
var time_hours: float = float(_current.get("time_hours", _get_time_hours()))
var sun_elevation := _sun_elevation01(time_hours)
_sky_material.sky_top_color = _sky_material.sky_top_color.lerp(sky_top, blend)
_sky_material.sky_horizon_color = _sky_material.sky_horizon_color.lerp(sky_horizon, blend)
_sky_material.ground_horizon_color = _sky_material.ground_horizon_color.lerp(sky_low, blend)
_sky_material.ground_bottom_color = _sky_material.ground_bottom_color.lerp(fog_color.darkened(0.55), blend)
_sky_material.sky_energy_multiplier = lerpf(_sky_material.sky_energy_multiplier, lerpf(0.28, 1.18, sun_elevation), blend)
_sky_material.ground_energy_multiplier = lerpf(_sky_material.ground_energy_multiplier, 0.45, blend)
_sky_material.sun_angle_max = 10.0
_sky_material.sun_curve = 0.08
_environment.ambient_light_color = _environment.ambient_light_color.lerp(ambient, blend)
_environment.ambient_light_energy = lerpf(_environment.ambient_light_energy, clamp(_color_luma(ambient) * 1.45, 0.18, 1.05), blend)
_environment.ambient_light_sky_contribution = lerpf(_environment.ambient_light_sky_contribution, 0.65, blend)
_environment.background_energy_multiplier = lerpf(_environment.background_energy_multiplier, lerpf(0.35, 0.95, sun_elevation), blend)
_environment.fog_light_color = _environment.fog_light_color.lerp(fog_color, blend)
_environment.fog_light_energy = lerpf(_environment.fog_light_energy, 0.55, blend)
_environment.fog_sun_scatter = lerpf(_environment.fog_sun_scatter, 0.08, blend)
_environment.fog_sky_affect = lerpf(_environment.fog_sky_affect, 0.35, blend)
var fog_end: float = maxf(800.0, float(_current.get("fog_end", 5200.0)))
var fog_start: float = clamp(float(_current.get("fog_start", fog_end * 0.25)), 0.0, fog_end - 50.0)
var dbc_fog_density: float = clamp(float(_current.get("fog_density", 0.5)), 0.0, 1.0)
_environment.fog_depth_begin = lerpf(_environment.fog_depth_begin, fog_start, blend)
_environment.fog_depth_end = lerpf(_environment.fog_depth_end, fog_end, blend)
_environment.fog_density = lerpf(_environment.fog_density, clamp(dbc_fog_density * 0.0002, 0.00002, 0.00022), blend)
if _sun:
var sun_color := diffuse.lerp(Color(0.42, 0.46, 0.7), 1.0 - sun_elevation)
_sun.light_color = _sun.light_color.lerp(sun_color, blend)
_sun.light_energy = lerpf(_sun.light_energy, clamp(_color_luma(diffuse) * lerpf(0.22, 1.45, sun_elevation), 0.05, 1.6), blend)
_apply_sun_direction(time_hours)
_update_wow_shader_globals(sun_elevation)
func _ensure_wow_shader_globals() -> void:
pass
func _update_wow_shader_globals(sun_elevation: float) -> void:
if not _environment:
return
var light_dir := Vector3(-0.35, 0.82, -0.45).normalized()
if _sun:
light_dir = -_sun.global_transform.basis.z.normalized()
var fog_range := Vector2(_environment.fog_depth_begin, _environment.fog_depth_end)
var raw_light: Color = _sun.light_color if _sun else Color(1.0, 0.91, 0.78, 1.0)
var shader_ambient := _world_shader_color(_environment.ambient_light_color, 0.65, Color(0.72, 0.70, 0.64, 1.0), 0.22)
var shader_light := _world_shader_color(raw_light, 0.45, Color(1.0, 0.92, 0.78, 1.0), 0.16)
var shader_fog := _world_shader_color(_environment.fog_light_color, 0.75, Color(0.62, 0.66, 0.66, 1.0), 0.38)
var density: float = clampf(_environment.fog_density * 2200.0, 0.0, 0.42)
var signature := "%s|%s|%s|%s|%.4f|%.3f" % [
str(shader_ambient),
str(shader_light),
str(light_dir.snapped(Vector3(0.001, 0.001, 0.001))),
str(fog_range.snapped(Vector2(1.0, 1.0))),
density,
sun_elevation]
if signature == _last_global_light_signature:
return
_last_global_light_signature = signature
RenderingServer.global_shader_parameter_set(&"wow_ambient_color", shader_ambient)
RenderingServer.global_shader_parameter_set(&"wow_light_color", shader_light)
RenderingServer.global_shader_parameter_set(&"wow_light_dir", light_dir)
RenderingServer.global_shader_parameter_set(&"wow_fog_color", shader_fog)
RenderingServer.global_shader_parameter_set(&"wow_fog_range", fog_range)
RenderingServer.global_shader_parameter_set(&"wow_fog_density", density)
RenderingServer.global_shader_parameter_set(&"wow_sun_elevation", sun_elevation)
func _load_lighting_dbcs() -> bool:
var base := _res_path(extracted_dir).path_join("DBFilesClient")
var light := _load_wdbc(base.path_join("Light.dbc"))
var light_params := _load_wdbc(base.path_join("LightParams.dbc"))
var light_skybox := _load_wdbc(base.path_join("LightSkybox.dbc"))
var int_band := _load_wdbc(base.path_join("LightIntBand.dbc"))
var float_band := _load_wdbc(base.path_join("LightFloatBand.dbc"))
var area_table := _load_wdbc(base.path_join("AreaTable.dbc"))
if light.is_empty() or int_band.is_empty() or float_band.is_empty():
return false
_load_light_volumes(light)
if not light_params.is_empty():
_load_light_params(light_params)
if not light_skybox.is_empty():
_load_light_skyboxes(light_skybox)
if not area_table.is_empty():
_load_area_table(area_table)
_load_int_bands(int_band)
_load_float_bands(float_band)
return not _profiles.is_empty()
func _load_light_volumes(dbc: Dictionary) -> void:
_light_volumes_by_map.clear()
_default_light_params_by_map.clear()
for i in int(dbc["records"]):
var record_map := _dbc_u32(dbc, i, 1)
var wx := _dbc_float(dbc, i, 2) / LIGHT_COORD_SCALE
var wz := _dbc_float(dbc, i, 3) / LIGHT_COORD_SCALE
var wy := _dbc_float(dbc, i, 4) / LIGHT_COORD_SCALE
var inner := maxf(0.0, _dbc_float(dbc, i, 5) / LIGHT_COORD_SCALE)
var outer := maxf(inner, _dbc_float(dbc, i, 6) / LIGHT_COORD_SCALE)
var normal := int(_dbc_u32(dbc, i, 7))
var rain := int(_dbc_u32(dbc, i, 8))
var underwater := int(_dbc_u32(dbc, i, 9))
if normal <= 0:
continue
if outer <= 0.001:
if not _default_light_params_by_map.has(record_map):
_default_light_params_by_map[record_map] = normal
continue
if not _light_volumes_by_map.has(record_map):
_light_volumes_by_map[record_map] = []
_light_volumes_by_map[record_map].append({
"pos": Vector3(wx, wy, wz),
"inner": inner,
"outer": outer,
"normal": normal,
"rain": rain,
"underwater": underwater,
})
func _load_int_bands(dbc: Dictionary) -> void:
for i in int(dbc["records"]):
var band_id := int(_dbc_u32(dbc, i, 0))
if band_id <= 0:
continue
var param_id := int((band_id - 1) / 18) + 1
var channel := (band_id - 1) % 18
var profile := _get_or_create_profile(param_id)
var colors: Array = profile["colors"]
colors[channel] = _read_color_band(dbc, i)
func _load_float_bands(dbc: Dictionary) -> void:
for i in int(dbc["records"]):
var band_id := int(_dbc_u32(dbc, i, 0))
if band_id <= 0:
continue
var param_id := int((band_id - 1) / 6) + 1
var channel := (band_id - 1) % 6
var profile := _get_or_create_profile(param_id)
var floats: Array = profile["floats"]
floats[channel] = _read_float_band(dbc, i)
func _load_light_params(dbc: Dictionary) -> void:
for i in int(dbc["records"]):
var param_id := int(_dbc_u32(dbc, i, 0))
if param_id <= 0:
continue
var profile := _get_or_create_profile(param_id)
profile["highlight_sky"] = int(_dbc_u32(dbc, i, 1))
profile["skybox_id"] = int(_dbc_u32(dbc, i, 2))
func _load_light_skyboxes(dbc: Dictionary) -> void:
_light_skyboxes.clear()
for i in int(dbc["records"]):
var skybox_id := int(_dbc_u32(dbc, i, 0))
if skybox_id <= 0:
continue
var path := _dbc_string(dbc, i, 1).replace("\\", "/")
if path.is_empty():
continue
_light_skyboxes[skybox_id] = {
"path": path,
"flags": int(_dbc_u32(dbc, i, 2)),
}
func _load_area_table(dbc: Dictionary) -> void:
_area_table.clear()
for i in int(dbc["records"]):
var area_id := int(_dbc_u32(dbc, i, 0))
if area_id <= 0:
continue
_area_table[area_id] = {
"map": int(_dbc_u32(dbc, i, 1)),
"parent": int(_dbc_u32(dbc, i, 2)),
"name": _dbc_string(dbc, i, 19),
}
func _get_or_create_profile(param_id: int) -> Dictionary:
if _profiles.has(param_id):
return _profiles[param_id]
var colors: Array = []
var floats: Array = []
colors.resize(18)
floats.resize(6)
var profile := {"colors": colors, "floats": floats}
_profiles[param_id] = profile
return profile
func _select_light_params(wow_pos: Vector3, area_id: int, zone_id: int) -> Array:
var volumes: Array = _light_volumes_by_map.get(map_id, [])
var weighted: Array = []
for volume in volumes:
var pos: Vector3 = volume["pos"]
var d := Vector2(wow_pos.x - pos.x, wow_pos.y - pos.y).length()
var outer: float = volume["outer"]
if d > outer:
continue
var inner: float = volume["inner"]
var weight := 1.0
if outer > inner:
weight = clamp((outer - d) / (outer - inner), 0.0, 1.0)
if weight > 0.0:
weighted.append({"id": int(volume["normal"]), "weight": weight, "source": "volume"})
weighted.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a["weight"]) > float(b["weight"]))
if weighted.size() > 4:
weighted.resize(4)
var total := 0.0
for item in weighted:
total += float(item["weight"])
if total > 0.0:
for item in weighted:
item["weight"] = float(item["weight"]) / total
return weighted
var area_profile := _select_area_light_param(area_id, zone_id)
if area_profile > 0:
return [{"id": area_profile, "weight": 1.0, "source": "area", "area": area_id, "zone": zone_id}]
var fallback := int(_default_light_params_by_map.get(map_id, _default_light_params_by_map.get(0, 12)))
return [{"id": fallback, "weight": 1.0, "source": "default"}]
func _select_area_light_param(area_id: int, zone_id: int) -> int:
if area_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(area_id):
return int(AREA_LIGHT_PARAMS_BY_ZONE[area_id])
if zone_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(zone_id):
return int(AREA_LIGHT_PARAMS_BY_ZONE[zone_id])
return 0
func _select_skybox_id(selected: Array) -> int:
if debug_force_skybox_id > 0:
return debug_force_skybox_id if _light_skyboxes.has(debug_force_skybox_id) else 0
for item in selected:
var id := int(item.get("id", 0))
if not _profiles.has(id):
continue
var skybox_id := int((_profiles[id] as Dictionary).get("skybox_id", 0))
if skybox_id > 0 and _light_skyboxes.has(skybox_id):
return skybox_id
return 0
func _sample_blended_params(selected: Array, time_half: int) -> Dictionary:
var out := {
"ambient": Color(0.72, 0.80, 0.88),
"diffuse": Color(1.0, 0.91, 0.78),
"fog_color": Color(0.55, 0.66, 0.72),
"sky_top": Color(0.13, 0.32, 0.62),
"sky_middle": Color(0.36, 0.58, 0.86),
"sky_band_1": Color(0.62, 0.76, 0.90),
"sky_band_2": Color(0.50, 0.62, 0.72),
"fog_end": 5200.0,
"fog_start": 1200.0,
"fog_density": 0.6,
"cloud_density": 0.0,
}
var accum := {}
for key in out.keys():
accum[key] = Color(0, 0, 0) if out[key] is Color else 0.0
var total := 0.0
for item in selected:
var id := int(item["id"])
var weight := float(item["weight"])
if not _profiles.has(id) or weight <= 0.0:
continue
var sampled := _sample_profile(_profiles[id], time_half, out)
for key in sampled.keys():
accum[key] = accum[key] + sampled[key] * weight
total += weight
if total <= 0.0:
return out
for key in out.keys():
out[key] = accum[key] if total == 1.0 else accum[key] / total
return out
func _sample_profile(profile: Dictionary, time_half: int, fallback: Dictionary) -> Dictionary:
var colors: Array = profile["colors"]
var floats: Array = profile["floats"]
var fog_end := _sample_float_band(floats[FOG_END], time_half, float(fallback["fog_end"]))
var fog_scalar := _sample_float_band(floats[FOG_START_SCALAR], time_half, 0.25)
return {
"ambient": _sample_color_band(colors[CH_AMBIENT], time_half, fallback["ambient"]),
"diffuse": _sample_color_band(colors[CH_DIFFUSE], time_half, fallback["diffuse"]),
"fog_color": _sample_color_band(colors[CH_FOG], time_half, fallback["fog_color"]),
"sky_top": _sample_color_band(colors[CH_SKY_TOP], time_half, fallback["sky_top"]),
"sky_middle": _sample_color_band(colors[CH_SKY_MIDDLE], time_half, fallback["sky_middle"]),
"sky_band_1": _sample_color_band(colors[CH_SKY_BAND_1], time_half, fallback["sky_band_1"]),
"sky_band_2": _sample_color_band(colors[CH_SKY_BAND_2], time_half, fallback["sky_band_2"]),
"fog_end": fog_end,
"fog_start": maxf(0.0, fog_end * fog_scalar),
"fog_density": _sample_float_band(floats[FOG_DENSITY], time_half, float(fallback["fog_density"])),
"cloud_density": _sample_float_band(floats[CLOUD_DENSITY], time_half, float(fallback["cloud_density"])),
}
func _read_color_band(dbc: Dictionary, record: int) -> Dictionary:
var count := mini(int(_dbc_u32(dbc, record, 1)), 16)
var times := PackedInt32Array()
var values: Array[Color] = []
for i in count:
times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY)
values.append(_dbc_color(_dbc_u32(dbc, record, 18 + i)))
return {"times": times, "values": values}
func _read_float_band(dbc: Dictionary, record: int) -> Dictionary:
var count := mini(int(_dbc_u32(dbc, record, 1)), 16)
var times := PackedInt32Array()
var values := PackedFloat32Array()
for i in count:
times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY)
values.append(_dbc_float(dbc, record, 18 + i))
return {"times": times, "values": values}
func _sample_color_band(band_variant: Variant, time_half: int, fallback: Color) -> Color:
if not (band_variant is Dictionary):
return fallback
var band: Dictionary = band_variant
var times: PackedInt32Array = band.get("times", PackedInt32Array())
var values: Array = band.get("values", [])
if times.is_empty() or values.is_empty():
return fallback
if times.size() == 1:
return values[0]
var pair := _find_time_pair(times, time_half)
var t := pair.z
return (values[pair.x] as Color).lerp(values[pair.y] as Color, t)
func _sample_float_band(band_variant: Variant, time_half: int, fallback: float) -> float:
if not (band_variant is Dictionary):
return fallback
var band: Dictionary = band_variant
var times: PackedInt32Array = band.get("times", PackedInt32Array())
var values: PackedFloat32Array = band.get("values", PackedFloat32Array())
if times.is_empty() or values.is_empty():
return fallback
if times.size() == 1:
return values[0]
var pair := _find_time_pair(times, time_half)
return lerpf(values[pair.x], values[pair.y], pair.z)
func _find_time_pair(times: PackedInt32Array, time_half: int) -> Vector3:
var idx1 := times.size() - 1
var idx2 := 0
for i in times.size():
if time_half < times[i]:
idx2 = i
idx1 = i - 1 if i > 0 else times.size() - 1
break
var t1 := times[idx1]
var t2 := times[idx2]
var span := t2 - t1 if t2 > t1 else HALF_MINUTES_PER_DAY - t1 + t2
var elapsed := time_half - t1 if time_half >= t1 else HALF_MINUTES_PER_DAY - t1 + time_half
var alpha: float = clamp(float(elapsed) / maxf(1.0, float(span)), 0.0, 1.0)
return Vector3(idx1, idx2, alpha)
func _load_wdbc(path: String) -> Dictionary:
var abs_path := ProjectSettings.globalize_path(path)
if not FileAccess.file_exists(abs_path):
return {}
var file := FileAccess.open(abs_path, FileAccess.READ)
if not file:
return {}
var bytes := file.get_buffer(file.get_length())
if bytes.size() < 20 or bytes[0] != 0x57 or bytes[1] != 0x44 or bytes[2] != 0x42 or bytes[3] != 0x43:
return {}
var records := int(bytes.decode_u32(4))
var fields := int(bytes.decode_u32(8))
var record_size := int(bytes.decode_u32(12))
var string_size := int(bytes.decode_u32(16))
var required := 20 + records * record_size + string_size
if records < 0 or fields <= 0 or record_size <= 0 or required > bytes.size():
return {}
return {
"bytes": bytes,
"records": records,
"fields": fields,
"record_size": record_size,
"records_offset": 20,
"strings_offset": 20 + records * record_size,
"string_size": string_size,
}
func _dbc_u32(dbc: Dictionary, record: int, field: int) -> int:
if record < 0 or record >= int(dbc["records"]) or field < 0:
return 0
var record_size := int(dbc["record_size"])
var field_offset := field * 4
if field_offset + 4 > record_size:
return 0
var bytes: PackedByteArray = dbc["bytes"]
return int(bytes.decode_u32(int(dbc["records_offset"]) + record * record_size + field_offset))
func _dbc_float(dbc: Dictionary, record: int, field: int) -> float:
if record < 0 or record >= int(dbc["records"]) or field < 0:
return 0.0
var record_size := int(dbc["record_size"])
var field_offset := field * 4
if field_offset + 4 > record_size:
return 0.0
var bytes: PackedByteArray = dbc["bytes"]
return bytes.decode_float(int(dbc["records_offset"]) + record * record_size + field_offset)
func _dbc_string(dbc: Dictionary, record: int, field: int) -> String:
var offset := _dbc_u32(dbc, record, field)
var string_size := int(dbc.get("string_size", 0))
if offset <= 0 or offset >= string_size:
return ""
var bytes: PackedByteArray = dbc["bytes"]
var pos := int(dbc["strings_offset"]) + offset
var end := pos
var max_end := int(dbc["strings_offset"]) + string_size
while end < max_end and bytes[end] != 0:
end += 1
if end <= pos:
return ""
return bytes.slice(pos, end).get_string_from_utf8()
func _dbc_color(value: int) -> Color:
var r := float(value & 0xFF) / 255.0
var g := float((value >> 8) & 0xFF) / 255.0
var b := float((value >> 16) & 0xFF) / 255.0
return Color(r, g, b, 1.0)
func _get_time_hours() -> float:
if use_system_time:
var now := Time.get_datetime_dict_from_system()
return float(now.hour) + float(now.minute) / 60.0 + float(now.second) / 3600.0
return fixed_time_hours
func _get_target_wow_position() -> Vector3:
var world_pos := Vector3.ZERO
if _target:
world_pos = _target.global_position
var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos))
return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards)
func _get_target_area_id() -> int:
if not _target or map_name.is_empty():
return 0
var world_pos := _target.global_position
var typed_world_position = _typed_godot_position(world_pos)
var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position)
var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y)
if areas.is_empty():
return 0
var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
return int(areas[chunk_y * 16 + chunk_x])
func _typed_godot_position(world_position: Vector3):
return GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)
func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array:
var key := "%d_%d" % [tile_x, tile_y]
if _adt_area_cache.has(key):
var cached: PackedInt32Array = _adt_area_cache[key]
return cached
var areas := PackedInt32Array()
areas.resize(256)
var rel_path := _res_path(extracted_dir).path_join("World/Maps").path_join(map_name).path_join("%s_%d_%d.adt" % [map_name, tile_x, tile_y])
var abs_path := ProjectSettings.globalize_path(rel_path)
if not FileAccess.file_exists(abs_path):
_adt_area_cache[key] = areas
return areas
var file := FileAccess.open(abs_path, FileAccess.READ)
if not file:
_adt_area_cache[key] = areas
return areas
var bytes := file.get_buffer(file.get_length())
var offset := 0
while offset + 8 <= bytes.size():
var payload := offset + 8
var size := int(bytes.decode_u32(offset + 4))
if size < 0 or payload + size > bytes.size():
offset += 1
continue
if _is_mcnk_magic(bytes, offset) and size >= 56:
var index_x := int(bytes.decode_u32(payload + 4))
var index_y := int(bytes.decode_u32(payload + 8))
var area_id := int(bytes.decode_u32(payload + 52))
if index_x >= 0 and index_x < 16 and index_y >= 0 and index_y < 16:
areas[index_y * 16 + index_x] = area_id
offset = payload + size
_adt_area_cache[key] = areas
return areas
func _is_mcnk_magic(bytes: PackedByteArray, offset: int) -> bool:
if offset + 4 > bytes.size():
return false
return (
(bytes[offset] == 0x4B and bytes[offset + 1] == 0x4E and bytes[offset + 2] == 0x43 and bytes[offset + 3] == 0x4D)
or (bytes[offset] == 0x4D and bytes[offset + 1] == 0x43 and bytes[offset + 2] == 0x4E and bytes[offset + 3] == 0x4B)
)
func _get_zone_area_id(area_id: int) -> int:
if area_id <= 0:
return 0
var current := area_id
var visited := {}
for i in 32:
if not _area_table.has(current):
return current
var area: Dictionary = _area_table[current]
var parent := int(area.get("parent", 0))
if parent <= 0:
return current
if visited.has(current):
return current
visited[current] = true
current = parent
return current
func _get_area_name(area_id: int) -> String:
if area_id <= 0 or not _area_table.has(area_id):
return "-"
var area: Dictionary = _area_table[area_id]
var name := str(area.get("name", ""))
return name if not name.is_empty() else "-"
func _update_skybox_model() -> void:
if not skybox_models_enabled or not _skybox_root:
return
var skybox_id := int(_current.get("skybox_id", 0))
if skybox_id == _active_skybox_id:
return
_active_skybox_id = skybox_id
if _active_skybox_node:
_active_skybox_node.queue_free()
_active_skybox_node = null
if skybox_id <= 0:
return
var rel_path := _get_skybox_m2_path(skybox_id)
if rel_path.is_empty():
return
var node := _load_skybox_m2(rel_path)
if not node:
return
node.name = "Skybox_%d" % skybox_id
node.scale = Vector3.ONE * maxf(0.001, skybox_model_scale)
_disable_skybox_shadows(node)
_skybox_root.add_child(node)
_active_skybox_node = node
print("SKYBOX_MODEL id=%d path=%s" % [skybox_id, rel_path])
func _update_skybox_transform() -> void:
if not _skybox_root:
return
var pos := Vector3.ZERO
if _target:
pos = _target.global_position
pos.y += skybox_model_height_offset
_skybox_root.global_position = pos
func _get_skybox_m2_path(skybox_id: int) -> String:
if not _light_skyboxes.has(skybox_id):
return ""
var rel_path := str((_light_skyboxes[skybox_id] as Dictionary).get("path", "")).replace("\\", "/")
if rel_path.is_empty():
return ""
if rel_path.get_extension().to_lower() == "mdx":
rel_path = rel_path.get_basename() + ".m2"
return rel_path
func _load_skybox_m2(rel_path: String) -> Node3D:
if _missing_skyboxes.has(rel_path):
return null
if not ClassDB.class_exists("M2Loader"):
return null
var abs_path := ProjectSettings.globalize_path(_res_path(extracted_dir).path_join(rel_path))
if not FileAccess.file_exists(abs_path):
_missing_skyboxes[rel_path] = true
push_warning("WowSkyController: missing skybox model: %s" % rel_path)
return null
var loader = ClassDB.instantiate("M2Loader")
if loader == null:
return null
var data: Dictionary = loader.call("load_m2", abs_path)
if data.is_empty():
_missing_skyboxes[rel_path] = true
return null
return M2_BUILDER_SCRIPT.build(data, extracted_dir)
func _disable_skybox_shadows(node: Node) -> void:
if node is GeometryInstance3D:
(node as GeometryInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
for child in node.get_children():
_disable_skybox_shadows(child)
func _apply_sun_direction(time_hours: float) -> void:
var day_phase := fposmod(time_hours - 6.0, 24.0) / 24.0
var azimuth := day_phase * TAU
var elevation := sin(clamp((time_hours - 6.0) / 12.0, 0.0, 1.0) * PI)
if time_hours < 6.0 or time_hours > 18.0:
elevation = -0.25
var horizontal := sqrt(maxf(0.0, 1.0 - elevation * elevation))
var sun_dir := Vector3(cos(azimuth) * horizontal, elevation, sin(azimuth) * horizontal).normalized()
var light_dir := -sun_dir
if abs(light_dir.dot(Vector3.UP)) > 0.98:
_sun.look_at(_sun.global_position + light_dir, Vector3.FORWARD)
else:
_sun.look_at(_sun.global_position + light_dir, Vector3.UP)
func _sun_elevation01(time_hours: float) -> float:
if time_hours < 6.0 or time_hours > 18.0:
return 0.0
return clamp(sin(((time_hours - 6.0) / 12.0) * PI), 0.0, 1.0)
func _color_luma(color: Color) -> float:
return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
func _world_shader_color(color: Color, desaturate: float, neutral: Color, neutral_mix: float) -> Color:
var luma := clampf(_color_luma(color), 0.0, 1.25)
var gray := Color(luma, luma, luma, color.a)
var result := color.lerp(gray, clampf(desaturate, 0.0, 1.0))
result = result.lerp(neutral, clampf(neutral_mix, 0.0, 1.0))
return Color(
clampf(result.r, 0.0, 1.25),
clampf(result.g, 0.0, 1.25),
clampf(result.b, 0.0, 1.25),
color.a)
func _get_light_volume_count(id: int) -> int:
return int((_light_volumes_by_map.get(id, []) as Array).size())
func _res_path(path: String) -> String:
return path.trim_suffix("/")