Карта Parkour: мрачный подъём по мегабашне, портал в хабе
- Новая карта maps/parkour: ~700 м, 9 чекпоинтов (светящиеся ворота), секции под мувсет: крыши, каньон из стен для бега, башня из уступов (двойной прыжок + подтягивание), лазерный зал (подкат, прыжок, тайминг), пропасть из контейнеров (зазоры 16–24 м), спираль вокруг ядра, вершина с порталом в хаб - Атмосфера: антиутопия — дождь, дымный туман, мокрый бетон с отражением неба, натриевый/красный свет, надписи-приказы, шум дождя и гул; освещение запечено в цвета вершин (Blender, Cycles), шейдер baked.gdshader без динамического света - Модель, раскладка и скрипты сборки лежат в «Blender models/parkour_02» (layout, build_parkour, bake, build_sky); parkour_data.gd генерируется - Пресет экспорта Parkour и строка в export_all.sh; карта исключена из основного пакета - Портал PARKOUR в хабе; README: карта, пересборка, замеры мувсета Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
extends Node3D
|
||||
# Паркур в стиле Ghostrunner: подъём по мегабашне от крыш до светящегося ядра. Сцена живёт в отдельном
|
||||
# пакете (parkour.pck); модель — parkour.glb, данные раскладки — parkour_data.gd: оба генерирует скрипт
|
||||
# `Blender models/parkour_02/build_parkour.py` из layout.py (там же описано, как считалась проходимость).
|
||||
# Упал ниже чекпоинта на KILL_DEPTH, попал в лазер — возрождение на последнем чекпоинте. Преследователей нет.
|
||||
|
||||
const Data = preload("res://maps/parkour/parkour_data.gd")
|
||||
const KILL_DEPTH := 32.0 # Ниже последнего чекпоинта на столько — срыв
|
||||
const GATE_HEIGHT := 5.0
|
||||
const GATE_HALF_WIDTH := 3.0
|
||||
|
||||
var player: Node3D
|
||||
var _lasers: Array = [] # {area, mat, mesh, mode, period, on, phase}
|
||||
var _hud_label: Label
|
||||
var _hud_timer := 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
Net.enter_map("parkour")
|
||||
player = get_tree().get_first_node_in_group("local_player")
|
||||
player.global_position = Data.START
|
||||
player.spawn_transform = Transform3D(Basis.IDENTITY, Data.START)
|
||||
player.death_label.text = "СОРВАЛСЯ!"
|
||||
_prepare_map()
|
||||
_build_environment()
|
||||
_build_hud()
|
||||
for pos in Data.CHECKPOINTS:
|
||||
_add_checkpoint(pos)
|
||||
for l in Data.LASERS:
|
||||
_add_laser(l)
|
||||
_add_rain()
|
||||
_add_ambience()
|
||||
_add_exit()
|
||||
|
||||
# Цвета поверхностей — те же значения, что в Blender (kit.py: SOLID); освещение уже запечено в цветах вершин
|
||||
const SURFACES := {
|
||||
"Concrete": Color(0.11, 0.115, 0.125), "Dark": Color(0.016, 0.017, 0.02), "Steel": Color(0.13, 0.14, 0.15),
|
||||
"PaintY": Color(0.38, 0.30, 0.06), "PaintR": Color(0.26, 0.08, 0.05), "PaintB": Color(0.05, 0.08, 0.11),
|
||||
"PaintO": Color(0.33, 0.14, 0.05),
|
||||
}
|
||||
# Светящиеся (мрачная палитра): Cyan — холодный белёсый, Magenta — тревожный красный, Orange — натриевая лампа
|
||||
const GLOWS := {
|
||||
"NeonCyan": [Color(0.50, 0.86, 0.82), 0.65], "NeonMagenta": [Color(0.95, 0.07, 0.04), 1.2],
|
||||
"NeonOrange": [Color(1.0, 0.42, 0.08), 1.3], "NeonYellow": [Color(0.92, 0.62, 0.10), 1.1],
|
||||
"NeonGreen": [Color(0.45, 0.85, 0.35), 1.0], "NeonBlue": [Color(0.25, 0.40, 0.70), 1.0],
|
||||
"NeonRed": [Color(1.0, 0.05, 0.05), 1.5], "NeonWhite": [Color(1.0, 0.72, 0.42), 0.9],
|
||||
}
|
||||
|
||||
# Коллизию создаёт импорт (узел Collision-colonly), здесь — внешний вид: материалы по именам из Blender
|
||||
func _prepare_map() -> void:
|
||||
var shader := load("res://maps/parkour/baked.gdshader") as Shader
|
||||
var noise := FastNoiseLite.new()
|
||||
noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||
noise.frequency = 0.04
|
||||
noise.fractal_octaves = 4
|
||||
var grain := NoiseTexture2D.new()
|
||||
grain.noise = noise
|
||||
grain.width = 512
|
||||
grain.height = 512
|
||||
grain.seamless = true
|
||||
var sky_tex := load("res://maps/parkour/sky.png") as Texture2D
|
||||
var cache := {}
|
||||
for mi: MeshInstance3D in $Map.find_children("*", "MeshInstance3D", true, false):
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
for s in mi.mesh.get_surface_count():
|
||||
var src := mi.mesh.surface_get_material(s)
|
||||
var key: String = src.resource_name if src != null else ""
|
||||
if not cache.has(key):
|
||||
if SURFACES.has(key):
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = shader
|
||||
m.set_shader_parameter("albedo_color", Vector3(SURFACES[key].r, SURFACES[key].g, SURFACES[key].b))
|
||||
m.set_shader_parameter("grain", grain)
|
||||
m.set_shader_parameter("sky_tex", sky_tex)
|
||||
m.set_shader_parameter("exposure", 2.1)
|
||||
cache[key] = m
|
||||
elif GLOWS.has(key):
|
||||
cache[key] = _emissive(GLOWS[key][0], GLOWS[key][1])
|
||||
else:
|
||||
cache[key] = null
|
||||
if cache[key] != null:
|
||||
mi.set_surface_override_material(s, cache[key])
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if player == null:
|
||||
return
|
||||
if not player.is_dead:
|
||||
if player.global_position.y < player.spawn_transform.origin.y - KILL_DEPTH:
|
||||
player.die()
|
||||
else:
|
||||
for l in _lasers:
|
||||
if _laser_active(l) and l.area.overlaps_body(player):
|
||||
player.die()
|
||||
break
|
||||
_update_lasers()
|
||||
_update_rain()
|
||||
if _hud_timer > 0.0:
|
||||
_hud_timer -= delta
|
||||
_hud_label.modulate.a = clampf(_hud_timer / 0.5, 0.0, 1.0)
|
||||
|
||||
# ---------- окружение ----------
|
||||
|
||||
func _build_environment() -> void:
|
||||
var sky_mat := PanoramaSkyMaterial.new()
|
||||
sky_mat.panorama = load("res://maps/parkour/sky.png")
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = sky_mat
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_SKY
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR # свет на самом игроке/призраках; уровень освещён запечённо
|
||||
env.ambient_light_color = Color(0.26, 0.29, 0.33)
|
||||
env.ambient_light_energy = 0.6
|
||||
env.fog_enabled = true
|
||||
env.fog_light_color = Color(0.2, 0.16, 0.13) # грязный дымный воздух
|
||||
env.fog_density = 0.011
|
||||
env.fog_sky_affect = 0.45
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
env.glow_enabled = true
|
||||
env.glow_intensity = 0.55
|
||||
env.glow_bloom = 0.1
|
||||
env.glow_hdr_threshold = 1.0
|
||||
env.adjustment_enabled = true
|
||||
env.adjustment_contrast = 1.15
|
||||
env.adjustment_saturation = 0.8
|
||||
var we := WorldEnvironment.new()
|
||||
we.environment = env
|
||||
add_child(we)
|
||||
var sun := DirectionalLight3D.new() # подсветка только для персонажей
|
||||
sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0)
|
||||
sun.light_color = Color(0.6, 0.68, 0.75)
|
||||
sun.light_energy = 0.4
|
||||
add_child(sun)
|
||||
|
||||
func _build_hud() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.layer = 12
|
||||
add_child(layer)
|
||||
_hud_label = Label.new()
|
||||
_hud_label.text = "ЧЕКПОИНТ"
|
||||
_hud_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_hud_label.add_theme_font_size_override("font_size", 44)
|
||||
_hud_label.add_theme_color_override("font_color", Color(0.5, 0.85, 0.4))
|
||||
_hud_label.add_theme_constant_override("outline_size", 8)
|
||||
_hud_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
_hud_label.modulate.a = 0.0
|
||||
layer.add_child(_hud_label)
|
||||
_hud_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
|
||||
_hud_label.position.y = 90.0
|
||||
|
||||
func _emissive(color: Color, energy: float, alpha := 1.0) -> StandardMaterial3D:
|
||||
var m := StandardMaterial3D.new()
|
||||
m.albedo_color = Color(0, 0, 0, alpha)
|
||||
m.emission_enabled = true
|
||||
m.emission = color
|
||||
m.emission_energy_multiplier = energy
|
||||
if alpha < 1.0:
|
||||
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
m.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
return m
|
||||
|
||||
# ---------- чекпоинты: высокие светящиеся ворота поперёк трассы ----------
|
||||
|
||||
func _add_checkpoint(pos: Vector3) -> void:
|
||||
var mat := _emissive(Color(1.0, 0.5, 0.15), 0.9)
|
||||
var sheet_mat := _emissive(Color(1.0, 0.5, 0.15), 0.3, 0.08)
|
||||
var root := Node3D.new()
|
||||
root.position = Vector3(pos.x, pos.y - 1.2, pos.z) # pos — центр капсулы, ворота ставим на пол
|
||||
add_child(root)
|
||||
for s in [-1.0, 1.0]:
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(0.35, GATE_HEIGHT, 0.35)
|
||||
root.add_child(_part(bm, mat, Vector3(s * GATE_HALF_WIDTH, GATE_HEIGHT * 0.5, 0)))
|
||||
var top := BoxMesh.new()
|
||||
top.size = Vector3(GATE_HALF_WIDTH * 2 + 0.35, 0.35, 0.35)
|
||||
root.add_child(_part(top, mat, Vector3(0, GATE_HEIGHT, 0)))
|
||||
var qm := QuadMesh.new() # слабая светящаяся «плёнка» в проёме
|
||||
qm.size = Vector2(GATE_HALF_WIDTH * 2, GATE_HEIGHT)
|
||||
root.add_child(_part(qm, sheet_mat, Vector3(0, GATE_HEIGHT * 0.5, 0)))
|
||||
var label := Label3D.new()
|
||||
label.text = "CHECKPOINT"
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.pixel_size = 0.012
|
||||
label.modulate = Color(1.0, 0.62, 0.3)
|
||||
label.outline_modulate = Color.BLACK
|
||||
label.position = Vector3(0, GATE_HEIGHT + 1.0, 0)
|
||||
root.add_child(label)
|
||||
|
||||
var area := Area3D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 1
|
||||
area.position = Vector3(0, GATE_HEIGHT * 0.5, 0)
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = Vector3(GATE_HALF_WIDTH * 2 + 6.0, GATE_HEIGHT + 2.0, 8.0)
|
||||
cs.shape = shape
|
||||
area.add_child(cs)
|
||||
root.add_child(area)
|
||||
area.body_entered.connect(func(body: Node3D) -> void:
|
||||
if body != player or player.spawn_transform.origin.is_equal_approx(pos):
|
||||
return
|
||||
player.spawn_transform = Transform3D(Basis.IDENTITY, pos)
|
||||
var green := Color(0.45, 0.85, 0.35)
|
||||
mat.emission = green
|
||||
sheet_mat.emission = green
|
||||
label.modulate = green
|
||||
_hud_timer = 2.0
|
||||
)
|
||||
|
||||
func _part(mesh: Mesh, mat: Material, pos: Vector3) -> MeshInstance3D:
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.material_override = mat
|
||||
mi.position = pos
|
||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
return mi
|
||||
|
||||
# ---------- лазеры ----------
|
||||
|
||||
func _add_laser(d: Array) -> void:
|
||||
var area := Area3D.new()
|
||||
area.collision_layer = 0
|
||||
area.collision_mask = 1
|
||||
area.position = d[0]
|
||||
var cs := CollisionShape3D.new()
|
||||
var shape := BoxShape3D.new()
|
||||
shape.size = d[1]
|
||||
cs.shape = shape
|
||||
area.add_child(cs)
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = d[1]
|
||||
var mat := _emissive(Color(1.0, 0.04, 0.05), 2.4, 0.35 if d[1].y > 3.0 else 1.0)
|
||||
var mesh := _part(bm, mat, Vector3.ZERO)
|
||||
area.add_child(mesh)
|
||||
add_child(area)
|
||||
_lasers.append({"area": area, "mat": mat, "mesh": mesh, "mode": d[2], "period": d[3], "on": d[4], "phase": d[5]})
|
||||
|
||||
# Пульсирующие лазеры считают фазу по системным часам — у всех игроков примерно одинаково
|
||||
func _laser_active(l: Dictionary) -> bool:
|
||||
if l.mode == 0:
|
||||
return true
|
||||
return fposmod(Time.get_unix_time_from_system() + l.phase, l.period) < l.on
|
||||
|
||||
func _update_lasers() -> void:
|
||||
for l in _lasers:
|
||||
var active := _laser_active(l)
|
||||
l.mat.emission_energy_multiplier = 2.4 if active else 0.06
|
||||
l.mesh.visible = active or l.mode == 1
|
||||
|
||||
# ---------- выход ----------
|
||||
|
||||
func _add_exit() -> void:
|
||||
var portal := Area3D.new()
|
||||
portal.set_script(load("res://objects/portal.gd"))
|
||||
portal.target_map = "hub"
|
||||
portal.target_scene = "res://maps/hub/hub.tscn"
|
||||
portal.title = "HUB"
|
||||
portal.color = Color(0.3, 1, 0.45)
|
||||
portal.position = Data.PORTAL
|
||||
add_child(portal)
|
||||
|
||||
# ---------- дождь и звук ----------
|
||||
|
||||
var _rain: GPUParticles3D
|
||||
|
||||
# Ливень вокруг игрока: тонкие вертикальные штрихи, летящие по ветру; в закрытом зале не идёт
|
||||
func _add_rain() -> void:
|
||||
_rain = GPUParticles3D.new()
|
||||
_rain.amount = 2200
|
||||
_rain.lifetime = 0.8
|
||||
_rain.local_coords = false
|
||||
_rain.visibility_aabb = AABB(Vector3(-30, -30, -30), Vector3(60, 60, 60))
|
||||
var pm := ParticleProcessMaterial.new()
|
||||
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
pm.emission_box_extents = Vector3(26.0, 0.5, 26.0)
|
||||
pm.direction = Vector3(0.12, -1.0, 0.05)
|
||||
pm.spread = 2.0
|
||||
pm.initial_velocity_min = 34.0
|
||||
pm.initial_velocity_max = 40.0
|
||||
pm.gravity = Vector3.ZERO
|
||||
_rain.process_material = pm
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2(0.018, 0.7)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.albedo_color = Color(0.7, 0.78, 0.82, 0.3)
|
||||
mat.billboard_mode = BaseMaterial3D.BILLBOARD_FIXED_Y
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
quad.material = mat
|
||||
_rain.draw_pass_1 = quad
|
||||
_rain.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
add_child(_rain)
|
||||
|
||||
func _update_rain() -> void:
|
||||
if _rain == null or player == null:
|
||||
return
|
||||
_rain.global_position = player.global_position + Vector3(0, 12, 0)
|
||||
var p := player.global_position
|
||||
_rain.emitting = not (p.z < -Data.HALL_Y0 + 2.0 and p.z > -Data.HALL_Y1 - 2.0 and absf(p.x) < 11.0 and p.y > Data.HALL_Z - 1.0 and p.y < Data.HALL_Z + 14.0)
|
||||
|
||||
# Низкий промышленный гул и шум дождя: 3 секунды без щелчка на стыке петли (края сведены перекрёстным затуханием)
|
||||
func _add_ambience() -> void:
|
||||
const RATE := 22050
|
||||
const FADE := 4410
|
||||
var n := RATE * 3
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 13
|
||||
var raw := PackedFloat32Array()
|
||||
raw.resize(n + FADE)
|
||||
var lp := 0.0
|
||||
for i in n + FADE:
|
||||
lp = lerpf(lp, rng.randf_range(-1.0, 1.0), 0.55) # мягкий «шипящий» шум
|
||||
raw[i] = lp
|
||||
var data := PackedByteArray()
|
||||
data.resize(n * 2)
|
||||
for i in n:
|
||||
var v := raw[i]
|
||||
if i < FADE:
|
||||
var t := float(i) / FADE
|
||||
v = raw[i] * sin(t * PI * 0.5) + raw[n + i] * cos(t * PI * 0.5)
|
||||
var tt := float(i) / RATE
|
||||
var hum := 0.16 * sin(TAU * 42.0 * tt) + 0.08 * sin(TAU * 63.0 * tt) + 0.05 * sin(TAU * 21.0 * tt)
|
||||
data.encode_s16(i * 2, int(clampf(v * 0.45 + hum, -1.0, 1.0) * 14000.0))
|
||||
var wav := AudioStreamWAV.new()
|
||||
wav.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
wav.mix_rate = RATE
|
||||
wav.stereo = false
|
||||
wav.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
wav.loop_begin = 0
|
||||
wav.loop_end = n
|
||||
wav.data = data
|
||||
var ap := AudioStreamPlayer.new()
|
||||
ap.stream = wav
|
||||
ap.volume_db = -13.0
|
||||
ap.autoplay = true
|
||||
add_child(ap)
|
||||
Reference in New Issue
Block a user