J1: the cliff tunnel (seam, six propped stretches, dark, lights), travel Valley <-> Cove through the sea cave, a save file per worldlet; New World has no Where (--dev-worldlets to try others)
This commit is contained in:
451
scripts/world/tunnel.gd
Normal file
451
scripts/world/tunnel.gd
Normal file
@@ -0,0 +1,451 @@
|
||||
extends Node3D
|
||||
## The cliff tunnel (J1, docs/WORLDLETS_DESIGN.md §10): the Valley's way down
|
||||
## to the Cove, dug by hand.
|
||||
##
|
||||
## - **The seam:** a cracked outcrop on the clifftop strip near the edge.
|
||||
## Only the antler pickaxe (tier 3) bites into it; once it gives, it's a
|
||||
## dark opening: [E] goes down into the tunnel.
|
||||
## - **The tunnel:** its own small space, out of sight beneath the island
|
||||
## (the heightmap can't have holes), so going in and out is a step through
|
||||
## a doorway. Six sloping stretches, each one turning back under the last
|
||||
## like a stair, about 24 m down in all. The end of the dug part is a rock
|
||||
## face; each stretch dug must be propped (2 wood, [E] at its mouth) before
|
||||
## the next face takes a blow. Nothing caves in: it's a cost, not a trap.
|
||||
## - **Dark:** no daylight reaches it (day_night.underground). Bring light:
|
||||
## candles and torch stands can be set anywhere on its floor, and stay.
|
||||
## - **The bottom:** the last stretch breaks into a sea cave; walking out of
|
||||
## its mouth is the crossing to the Cove (world.travel()).
|
||||
##
|
||||
## How far it's dug, the face's damage and the props are saved with the
|
||||
## Valley (to_data()); the lights are ordinary placed pieces. Single-player
|
||||
## for now: on a server it isn't built (J3 brings travel online).
|
||||
|
||||
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
||||
|
||||
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
||||
const STRETCHES := 6
|
||||
const RUN := 6.0 ## each stretch: this far along...
|
||||
const DROP := 4.0 ## ...and this far down
|
||||
const W := 2.6 ## passage width
|
||||
const H := 3.0 ## passage height
|
||||
const LAND := 3.4 ## landing length, where it turns back
|
||||
const LAND_EXT := 2.0 ## how far a landing's headroom reaches into its stretches
|
||||
const GAP := 1.4 ## rock between the two lanes
|
||||
const CAVE_LEN := 7.0 ## the sea cave passage at the bottom
|
||||
const PROP_WOOD := 2
|
||||
const FACE_HP := 45.0 ## 15 blows of the antler pickaxe
|
||||
const SEAM_HP := 30.0
|
||||
|
||||
var world: Node3D
|
||||
var terrain: Node3D
|
||||
var to := "cove" ## the worldlet at the bottom
|
||||
|
||||
# Saved state.
|
||||
var opened := false ## the seam has given way
|
||||
var dug := 0 ## stretches dug (0..STRETCHES)
|
||||
var face_hp := FACE_HP ## damage to the face being dug (the seam's, before it opens)
|
||||
var props: Array = [] ## props[k] for stretch k (1-based; [0] unused)
|
||||
|
||||
# Where things are (world coordinates).
|
||||
var seam_at := Vector3.ZERO ## the seam, on the ground
|
||||
var seam_out := Vector3.ZERO ## where you stand outside it (inland)
|
||||
var top := Vector3.ZERO ## inside, at the top
|
||||
var bottom := Vector3.ZERO ## inside, at the bottom by the cave
|
||||
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
||||
var aabb := AABB()
|
||||
|
||||
var _rock_mat: StandardMaterial3D
|
||||
var _face_mat: StandardMaterial3D
|
||||
var _wood_mat: StandardMaterial3D
|
||||
var _rock: CSGCombiner3D
|
||||
var _cuts: Node3D
|
||||
var _face: StaticBody3D
|
||||
var _seam: Node3D
|
||||
var _spots: Node3D
|
||||
|
||||
## Builds the tunnel for `w` if its worldlet has a way on by tunnel from
|
||||
## its sea cliff. Returns false (and builds nothing) otherwise.
|
||||
func setup(w: Node3D) -> bool:
|
||||
world = w
|
||||
terrain = w.terrain
|
||||
var way: Dictionary = {}
|
||||
for c in w.crossings():
|
||||
if c is Dictionary and c.get("by", "") == "tunnel" and c.get("where", "") == "sea_cliff":
|
||||
way = c
|
||||
if way.is_empty() or not terrain._has_sea:
|
||||
return false
|
||||
to = String(way.to)
|
||||
props.resize(STRETCHES + 1)
|
||||
props.fill(false)
|
||||
_make_materials()
|
||||
_place_seam()
|
||||
_layout()
|
||||
_rebuild()
|
||||
add_to_group("tunnel")
|
||||
return true
|
||||
|
||||
# --- where it is ----------------------------------------------------------------
|
||||
|
||||
func _lane_z(k: int) -> float:
|
||||
return 0.0 if k % 2 == 1 else W + GAP
|
||||
|
||||
func _landing_x(k: int) -> float:
|
||||
return (k % 2) * (LAND + RUN)
|
||||
|
||||
func _floor_y(k: int) -> float:
|
||||
return -k * DROP
|
||||
|
||||
## Inside the tunnel's space (with a margin), in world coordinates.
|
||||
func contains(p: Vector3) -> bool:
|
||||
return aabb.grow(1.0).has_point(p)
|
||||
|
||||
func _layout() -> void:
|
||||
var x0 := -LAND * 0.5 - 3.0 - CAVE_LEN
|
||||
var x1 := LAND * 1.5 + RUN + 3.0
|
||||
var y0 := _floor_y(STRETCHES) - 3.0
|
||||
var y1 := H + 3.0
|
||||
var z0 := -W * 0.5 - 3.0
|
||||
var z1 := W * 1.5 + GAP + 3.0
|
||||
aabb = AABB(ORIGIN + Vector3(x0, y0, z0), Vector3(x1 - x0, y1 - y0, z1 - z0))
|
||||
top = ORIGIN + Vector3(_landing_x(0) - LAND * 0.25, 0.0, _lane_z(1))
|
||||
var zb := _lane_z(STRETCHES)
|
||||
bottom = ORIGIN + Vector3(_landing_x(STRETCHES), _floor_y(STRETCHES), zb)
|
||||
cave_mouth = bottom + Vector3(-LAND * 0.5 - CAVE_LEN + 0.6, 0.0, 0.0)
|
||||
|
||||
## The seam: on the clifftop strip, a few metres in from the edge, at an
|
||||
## angle the seed picks.
|
||||
func _place_seam() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = terrain.noise_seed + 404
|
||||
for _try in range(40):
|
||||
var a: float = terrain.sea_angle_deg + rng.randf_range(-18.0, 18.0)
|
||||
var p: Vector2 = terrain.coast_point(a, -7.0)
|
||||
if not terrain.inside_boundary(p.x, p.y, 2.0):
|
||||
continue
|
||||
var n: Vector3 = terrain.sample_normal(p.x, p.y)
|
||||
if n.y < 0.9:
|
||||
continue
|
||||
seam_at = Vector3(p.x, terrain.sample_height(p.x, p.y), p.y)
|
||||
break
|
||||
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
|
||||
seam_out = seam_at + inland * 2.2
|
||||
seam_out.y = terrain.sample_height(seam_out.x, seam_out.z)
|
||||
|
||||
# --- digging ---------------------------------------------------------------------
|
||||
|
||||
## Why the face at `stage` won't take a blow ("" = it will).
|
||||
func dig_refusal(stage: int) -> String:
|
||||
if stage >= 2:
|
||||
for k in range(1, stage):
|
||||
if not props[k]:
|
||||
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
|
||||
return ""
|
||||
|
||||
func face_hit(f: Node) -> void:
|
||||
face_hp = f.hp
|
||||
|
||||
func face_broken(f: Node) -> void:
|
||||
face_hp = FACE_HP
|
||||
if f.stage == 0:
|
||||
opened = true
|
||||
Actions.notify.emit("The seam gives way: a dark passage leads down into the cliff")
|
||||
else:
|
||||
dug = f.stage
|
||||
if dug >= STRETCHES:
|
||||
Actions.notify.emit("You break through into a sea cave. You can hear the waves")
|
||||
else:
|
||||
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
||||
_rebuild.call_deferred()
|
||||
|
||||
## [E] at the mouth of stretch `k`: sets a timber prop there.
|
||||
func set_prop(player: Node, k: int) -> bool:
|
||||
if k < 1 or k > dug or props[k]:
|
||||
return false
|
||||
if player.inventory.count("wood") < PROP_WOOD:
|
||||
Actions.notify.emit("A timber prop needs %d wood" % PROP_WOOD)
|
||||
return false
|
||||
player.inventory.remove("wood", PROP_WOOD)
|
||||
props[k] = true
|
||||
Actions.notify.emit("Propped. The next face is safe to dig")
|
||||
_rebuild.call_deferred()
|
||||
return true
|
||||
|
||||
# --- going in and out ----------------------------------------------------------
|
||||
|
||||
func go_in(player: Node3D) -> void:
|
||||
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
|
||||
|
||||
func go_out(player: Node3D) -> void:
|
||||
_move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized())
|
||||
|
||||
func _move(player: Node3D, at: Vector3, facing: Vector3) -> void:
|
||||
player.global_position = at
|
||||
player.velocity = Vector3.ZERO
|
||||
player.rotation = Vector3(0.0, atan2(-facing.x, -facing.z), 0.0)
|
||||
|
||||
# --- saving ----------------------------------------------------------------------
|
||||
|
||||
func to_data() -> Dictionary:
|
||||
var p: Array = []
|
||||
for k in range(1, STRETCHES + 1):
|
||||
if props[k]:
|
||||
p.append(k)
|
||||
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
|
||||
|
||||
func load_data(d: Dictionary) -> void:
|
||||
opened = bool(d.get("opened", false))
|
||||
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
||||
face_hp = float(d.get("face_hp", FACE_HP))
|
||||
props.fill(false)
|
||||
for k in d.get("props", []):
|
||||
if int(k) >= 1 and int(k) <= STRETCHES:
|
||||
props[int(k)] = true
|
||||
_rebuild()
|
||||
|
||||
# --- building it ---------------------------------------------------------------
|
||||
|
||||
func _make_materials() -> void:
|
||||
_rock_mat = StandardMaterial3D.new()
|
||||
_rock_mat.albedo_color = Color(0.40, 0.38, 0.36)
|
||||
_rock_mat.roughness = 1.0
|
||||
if terrain.noise_tex != null:
|
||||
_rock_mat.albedo_texture = terrain.noise_tex
|
||||
_rock_mat.uv1_triplanar = true
|
||||
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
||||
_face_mat = _rock_mat.duplicate()
|
||||
_face_mat.albedo_color = Color(0.30, 0.27, 0.25)
|
||||
_wood_mat = StandardMaterial3D.new()
|
||||
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
||||
_wood_mat.roughness = 0.9
|
||||
|
||||
func _rebuild() -> void:
|
||||
for n in [_rock, _seam, _spots, _face]:
|
||||
if n != null and is_instance_valid(n):
|
||||
n.queue_free()
|
||||
_face = null
|
||||
_build_rock()
|
||||
_build_seam()
|
||||
_spots = Node3D.new()
|
||||
_spots.name = "Spots"
|
||||
_spots.top_level = true
|
||||
add_child(_spots)
|
||||
# The face being dug: at the mouth of the next stretch (or the seam).
|
||||
if opened and dug < STRETCHES:
|
||||
var k := dug + 1
|
||||
var mouth := _mouth(k)
|
||||
_face = _make_face(k, mouth + Vector3(0, H * 0.5, 0), Vector3(0.5, H, W + 0.2))
|
||||
# Props set (posts and a beam), and spots to set the missing ones.
|
||||
for k in range(1, dug + 1):
|
||||
var mouth := _mouth(k)
|
||||
if props[k]:
|
||||
_add_prop_mesh(mouth)
|
||||
else:
|
||||
_add_spot(mouth + Vector3(0, H * 0.5, 0), Vector3(0.6, H, W), "prop", k)
|
||||
_add_spot(ORIGIN + Vector3(_landing_x(0) - LAND * 0.5 + 0.35, H * 0.5, _lane_z(1)), Vector3(0.4, H, W), "out", 0)
|
||||
if dug >= STRETCHES:
|
||||
_add_mouth()
|
||||
|
||||
## The start of stretch k, on the floor of landing k-1 (world coordinates).
|
||||
func _mouth(k: int) -> Vector3:
|
||||
var s := signf(_landing_x(k) - _landing_x(k - 1))
|
||||
return ORIGIN + Vector3(_landing_x(k - 1) + s * LAND * 0.5, _floor_y(k - 1), _lane_z(k))
|
||||
|
||||
func _build_rock() -> void:
|
||||
_rock = CSGCombiner3D.new()
|
||||
_rock.name = "Rock"
|
||||
_rock.use_collision = true
|
||||
_rock.collision_layer = 1
|
||||
_rock.top_level = true
|
||||
add_child(_rock)
|
||||
_rock.global_position = ORIGIN
|
||||
var base := CSGBox3D.new()
|
||||
base.size = aabb.size
|
||||
base.position = aabb.get_center() - ORIGIN
|
||||
base.material = _rock_mat
|
||||
_rock.add_child(base)
|
||||
if not opened:
|
||||
return
|
||||
# Landings: the top one on lane A only; the rest span both lanes.
|
||||
for k in range(0, dug + 1):
|
||||
var x := _landing_x(k)
|
||||
var y := _floor_y(k)
|
||||
var zlo := -W * 0.5 if k < STRETCHES else _lane_z(k) - W * 0.5
|
||||
var zhi := W * 0.5 if k == 0 else W * 1.5 + GAP
|
||||
if k == STRETCHES:
|
||||
zhi = _lane_z(k) + W * 0.5
|
||||
_cut(Vector3(x, y + H * 0.5, (zlo + zhi) * 0.5), Vector3(LAND, H, zhi - zlo), 0.0)
|
||||
# Headroom LAND_EXT on into the stretch going down from here (in its
|
||||
# lane only): a sloping cut's end is square to the slope, so on its
|
||||
# own it would leave a lip of rock across the top of it at head height.
|
||||
if k < dug:
|
||||
var side := signf(_landing_x(k + 1) - x)
|
||||
_cut(Vector3(x + side * (LAND + LAND_EXT) * 0.5, y + H * 0.5, _lane_z(k + 1)), Vector3(LAND_EXT, H, W), 0.0)
|
||||
# The stretches dug: sloping boxes from one landing down to the next.
|
||||
for k in range(1, dug + 1):
|
||||
var a := _mouth(k) - ORIGIN
|
||||
var s := signf(_landing_x(k) - _landing_x(k - 1))
|
||||
var b := Vector3(_landing_x(k) - s * LAND * 0.5, _floor_y(k), _lane_z(k))
|
||||
var d := b - a
|
||||
var length := Vector2(d.x, d.y).length()
|
||||
var ang := atan2(d.y, d.x)
|
||||
var up := Vector3(-sin(ang), cos(ang), 0.0)
|
||||
if up.y < 0.0:
|
||||
up = -up
|
||||
_cut((a + b) * 0.5 + up * H * 0.5, Vector3(length + 0.3, H, W), ang)
|
||||
# The sea cave, off the bottom landing.
|
||||
if dug >= STRETCHES:
|
||||
var c := bottom - ORIGIN + Vector3(-LAND * 0.5 - CAVE_LEN * 0.5, 0.0, 0.0)
|
||||
_cut(c + Vector3(0, H * 0.6, 0), Vector3(CAVE_LEN + 0.2, H * 1.2, W + 0.8), 0.0)
|
||||
|
||||
func _cut(center: Vector3, size: Vector3, z_rot: float) -> void:
|
||||
var b := CSGBox3D.new()
|
||||
b.operation = CSGShape3D.OPERATION_SUBTRACTION
|
||||
b.size = size
|
||||
b.position = center
|
||||
b.rotation = Vector3(0.0, 0.0, z_rot)
|
||||
b.material = _rock_mat
|
||||
_rock.add_child(b)
|
||||
|
||||
func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D:
|
||||
var f: StaticBody3D = TunnelFace.new()
|
||||
f.name = "Face%d" % stage
|
||||
f.tunnel = self
|
||||
f.stage = stage
|
||||
f.uid = "tunnel_face_%d" % stage
|
||||
f.max_hp = SEAM_HP if stage == 0 else FACE_HP
|
||||
f.hp = clampf(face_hp, 0.5, f.max_hp)
|
||||
f.top_level = true
|
||||
f.collision_layer = 1
|
||||
f.add_to_group("harvestable")
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = size
|
||||
cs.shape = box
|
||||
f.add_child(cs)
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = size
|
||||
mi.mesh = bm
|
||||
mi.material_override = _face_mat
|
||||
f.add_child(mi)
|
||||
add_child(f)
|
||||
f.global_position = center
|
||||
return f
|
||||
|
||||
## The seam on the clifftop: an outcrop with a cracked face, then (dug) a
|
||||
## dark doorway into the cliff.
|
||||
func _build_seam() -> void:
|
||||
_seam = Node3D.new()
|
||||
_seam.name = "Seam"
|
||||
_seam.top_level = true
|
||||
add_child(_seam)
|
||||
var inland := -Vector3(seam_at.x, 0.0, seam_at.z).normalized()
|
||||
_seam.global_transform = Transform3D(Basis.looking_at(inland, Vector3.UP), seam_at)
|
||||
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
||||
var body := StaticBody3D.new()
|
||||
_seam.add_child(body)
|
||||
for part in [[Vector3(-1.5, 1.2, 0.4), Vector3(1.0, 2.6, 2.0)], [Vector3(1.5, 1.1, 0.4), Vector3(1.0, 2.4, 2.0)],
|
||||
[Vector3(0, 2.6, 0.4), Vector3(4.0, 0.9, 2.2)], [Vector3(0, 1.1, 1.1), Vector3(2.2, 2.4, 0.6)]]:
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = part[1]
|
||||
mi.mesh = bm
|
||||
mi.material_override = _rock_mat
|
||||
mi.position = part[0]
|
||||
_seam.add_child(mi)
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = part[1]
|
||||
cs.shape = box
|
||||
cs.position = part[0]
|
||||
body.add_child(cs)
|
||||
if not opened:
|
||||
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5))
|
||||
_face.global_basis = _seam.global_basis
|
||||
else:
|
||||
var dark := MeshInstance3D.new()
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(2.0, 2.1)
|
||||
dark.mesh = q
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.02, 0.02, 0.02)
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
dark.material_override = m
|
||||
dark.position = Vector3(0, 1.05, 0.78)
|
||||
dark.rotation.y = PI # (a quad faces +Z: turn it to face inland)
|
||||
_seam.add_child(dark)
|
||||
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
||||
_seam.add_child(spot)
|
||||
spot.position = Vector3(0, 1.05, -0.2)
|
||||
|
||||
## A box you can look at and press E on (layer 2: you walk through it).
|
||||
func _spot_body(size: Vector3, what: String, k: int) -> StaticBody3D:
|
||||
var s := StaticBody3D.new()
|
||||
s.set_script(preload("res://scripts/world/tunnel_spot.gd"))
|
||||
s.tunnel = self
|
||||
s.what = what
|
||||
s.k = k
|
||||
s.collision_layer = 2
|
||||
s.collision_mask = 0
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = size
|
||||
cs.shape = box
|
||||
s.add_child(cs)
|
||||
return s
|
||||
|
||||
func _add_spot(center: Vector3, size: Vector3, what: String, k: int) -> void:
|
||||
var s := _spot_body(size, what, k)
|
||||
_spots.add_child(s)
|
||||
s.global_position = center
|
||||
|
||||
## Two posts and a beam across the mouth of a stretch.
|
||||
func _add_prop_mesh(mouth: Vector3) -> void:
|
||||
for part in [[Vector3(0, H * 0.5 - 0.1, -W * 0.5 + 0.18), Vector3(0.22, H - 0.2, 0.22)],
|
||||
[Vector3(0, H * 0.5 - 0.1, W * 0.5 - 0.18), Vector3(0.22, H - 0.2, 0.22)],
|
||||
[Vector3(0, H - 0.25, 0), Vector3(0.26, 0.24, W)]]:
|
||||
var mi := MeshInstance3D.new()
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = part[1]
|
||||
mi.mesh = bm
|
||||
mi.material_override = _wood_mat
|
||||
_spots.add_child(mi)
|
||||
mi.global_position = mouth + part[0]
|
||||
|
||||
## The sea cave's mouth: grey daylight and the sound of the sea; walk into
|
||||
## it to go out onto the Cove's shore.
|
||||
func _add_mouth() -> void:
|
||||
var glow := MeshInstance3D.new()
|
||||
var q := QuadMesh.new()
|
||||
q.size = Vector2(W + 0.8, H * 1.2)
|
||||
glow.mesh = q
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0.75, 0.82, 0.88)
|
||||
m.emission_enabled = true
|
||||
m.emission = Color(0.6, 0.7, 0.78)
|
||||
m.emission_energy_multiplier = 1.6
|
||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
glow.material_override = m
|
||||
_spots.add_child(glow)
|
||||
glow.global_position = cave_mouth + Vector3(-0.55, H * 0.6, 0.0)
|
||||
glow.rotation.y = PI * 0.5
|
||||
var light := OmniLight3D.new()
|
||||
light.light_color = Color(0.7, 0.8, 0.9)
|
||||
light.light_energy = 1.2
|
||||
light.omni_range = 6.0
|
||||
_spots.add_child(light)
|
||||
light.global_position = cave_mouth + Vector3(0.8, H * 0.6, 0.0)
|
||||
var area := Area3D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 1 | 2 | 4 | 8 | 16 | 32
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = Vector3(1.0, H, W + 0.6)
|
||||
cs.shape = box
|
||||
area.add_child(cs)
|
||||
_spots.add_child(area)
|
||||
area.global_position = cave_mouth + Vector3(0, H * 0.5, 0)
|
||||
area.body_entered.connect(func(b):
|
||||
if b == world.player:
|
||||
world.travel(to, "sea_cave"))
|
||||
_add_spot(cave_mouth + Vector3(1.2, H * 0.5, 0), Vector3(0.4, H, W), "cave", 0)
|
||||
Reference in New Issue
Block a user