939 lines
33 KiB
GDScript
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("/")
|