class_name LiquidVolume
extends Node3D
## A Quake liquid volume (water / lava / slime) recovered from a .MAP import.
##
## Renders a warped surface (faithful gl_warp.c EmitWaterPolys via liquid_warp.gdshader) at
## the volume's top face using the real WAD liquid texture, and registers its world-space
## AABB in the "liquid_volumes" group so the player can test:
## • camera-in-liquid → full-screen content color-shift (V_CalcBlend), and
## • body-in-liquid → SV_WaterMove swim physics.
## Non-solid (matches Quake) — no collision body; damage/telefrag are a later session.
const WARP_SHADER: Shader = preload("res://scenes/maps/liquid_warp.gdshader")
var liquid_class: String = "water" # water | lava | slime | teleport
var aabb_min: Vector3 = Vector3.ZERO
var aabb_max: Vector3 = Vector3.ZERO
var top_y: float = 0.0
var visual_only: bool = false # true = render warp surface but no swim/blend group (teleport pads)
## Build a LiquidVolume from an importer `liquid_volumes` entry.
## tex_array — Texture2DArray of the map's liquid textures (from QuakeWadLoader.load).
## layer — this liquid texture's layer index in tex_array.
## world_scale — meters per Quake unit (same value passed to the importer).
const MIN_HALF := 0.05 # cull degenerate (near-zero-thickness) brush volumes
static func create(entry: Dictionary, tex_array: Texture2DArray, layer: int,
world_scale: float, visual_only: bool = false) -> LiquidVolume:
var half: Vector3 = entry.get("half", Vector3.ONE)
if half.x < MIN_HALF or half.y < MIN_HALF or half.z < MIN_HALF:
return null # degenerate sliver — no swimmable body, skip it
var lv := LiquidVolume.new()
lv.visual_only = visual_only
lv.liquid_class = String(entry.get("class", "water"))
lv.aabb_min = entry.get("aabb_min", Vector3.ZERO)
lv.aabb_max = entry.get("aabb_max", Vector3.ZERO)
lv.top_y = float(entry.get("top_y", 0.0))
var center: Vector3 = entry.get("center", Vector3.ZERO)
lv.name = "Liquid_%s" % lv.liquid_class
lv.position = center
# Render the whole liquid BOX (all faces, cull_disabled in the shader) so the volume reads
# with depth from outside and warps around you when submerged — not a floating lid. Inset a
# hair so faces don't z-fight the solid basin walls the water sits in. The warp is done
# per-fragment from world position (face-aware), so no subdivision is needed.
var box := BoxMesh.new()
box.size = half * 2.0 - Vector3(0.03, 0.03, 0.03) # half >= MIN_HALF → always positive
var mi := MeshInstance3D.new()
mi.name = "Body"
mi.mesh = box
var mat := ShaderMaterial.new()
mat.shader = WARP_SHADER
if tex_array != null:
mat.set_shader_parameter("tex", tex_array)
mat.set_shader_parameter("layer", float(layer))
mat.set_shader_parameter("world_scale", world_scale)
mi.material_override = mat
lv.add_child(mi)
return lv
func _ready() -> void:
# Teleport pads render the warp but must NOT be swum in / trigger the screen blend.
if not visual_only:
add_to_group("liquid_volumes")
func contains(p: Vector3) -> bool:
return p.x >= aabb_min.x and p.x <= aabb_max.x \
and p.y >= aabb_min.y and p.y <= aabb_max.y \
and p.z >= aabb_min.z and p.z <= aabb_max.z
## The liquid at `point` (highest surface wins on overlap, matching Quake's view-contents
## test), or {} if the point is in no liquid. Returns {class, top_y}.
static func content_at(tree: SceneTree, point: Vector3) -> Dictionary:
var best: Dictionary = {}
var best_top: float = -INF
for n in tree.get_nodes_in_group("liquid_volumes"):
var lv := n as LiquidVolume
if lv != null and lv.contains(point) and lv.top_y > best_top:
best_top = lv.top_y
best = {"class": lv.liquid_class, "top_y": lv.top_y}
return best