Карта Race: гонка на ногах по киберпанк-антиутопии, 8 игроков

- maps/race: трасса 633 м из Blender (три развилки: срез по контейнерам, каньон со стенами
  против нижней дороги, труба с лазерами против коридора), запечённый свет, небо, дождь, гул;
  фоновая песня (Ogg Vorbis, ~0,6 МБ) включается на «СТАРТ!»
- Правила (race_rules.gd): R — готов; когда готовы все — отсчёт 6 с, заморозка в слотах,
  ворота строго по порядку, сплиты, место, итоги, личный рекорд (user://race_best.cfg)
- Сеть: Net.submit_race / recv_race / race_event (RACE_READY, START, CP, FINISH, STATE).
  ВНИМАНИЕ: набор RPC изменён — клиент и сервер выкладываются вместе, старый клиент
  с новым сервером не работает (rpc node checksum failed)
- player.gd: input_frozen и place() для режимов карт; project.godot: действие ready (R)
- Портал RACE в хабе, пресет экспорта Race (include_filter для ресурсов, грузимых строкой)
- tools/test: двухклиентный тест гонки на локальном сервере, бот, облёт, проверка пакета
- README: правила гонки, события, проверки, заметки

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
This commit is contained in:
2026-10-28 17:28:05 +05:00
co-authored by Claude Sonnet 5.5
parent 2a6397da79
commit 9130202a51
31 changed files with 1775 additions and 4 deletions
+354
View File
@@ -0,0 +1,354 @@
extends Node3D
# Карта «Гонка» (RACE): забег на ногах по мрачному городу — от стартовой площадки через рынок, эстакаду, крыши, каньон и ангар
# до финиша. Модель — race.glb, данные — race_data.gd (оба генерирует `Blender models/race_01/build_race.py` из layout.py).
# Здесь: внешний вид (материалы по именам из GLB, окружение, дождь, гул), ворота, лазеры, срыв, возрождение на последних воротах.
# Правила гонки (ожидание, отсчёт, место, итоги, сеть) — в race_rules.gd; сервер про них ничего не знает.
const Data = preload("res://maps/race/race_data.gd")
const Rules = preload("res://maps/race/race_rules.gd")
const KILL_DEPTH := 32.0 # Ниже последних пройденных ворот на столько — срыв (то же значение в race_data.gd)
const GATE_HEIGHT := 5.0
const FLOOR_TO_CENTER := 1.2 # Центр капсулы над полом в данных ворот и слотов
var player: Node3D
var rules: Node
var _lasers: Array = [] # {area, mat, mesh, mode, period, on, phase}
var _gates: Array = [] # {root, mat, sheet, label} — визуал ворот по индексам CHECKPOINTS
func _ready() -> void:
Net.enter_map("race")
player = get_tree().get_first_node_in_group("local_player")
var start: Vector3 = Data.START_SLOTS[0]
player.global_position = start
player.spawn_transform = Transform3D(Basis.IDENTITY, start)
player.death_label.text = "СОРВАЛСЯ!"
_prepare_map()
_build_environment()
for i in Data.CHECKPOINTS.size():
_add_gate(i)
for l in Data.LASERS:
_add_laser(l)
_add_rain()
_add_ambience()
_add_exit()
rules = Rules.new()
rules.name = "Rules"
rules.setup(self)
add_child(rules)
# Цвета поверхностей — те же значения, что в 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/race/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/race/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()
# ---------- окружение ----------
func _build_environment() -> void:
var sky_mat := PanoramaSkyMaterial.new()
sky_mat.panorama = load("res://maps/race/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 _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
# ---------- ворота: высокие светящиеся рамы поперёк трассы ----------
const GATE_OFF := Color(0.55, 0.30, 0.14) # ещё не очередные: тусклые
const GATE_NEXT := Color(1.0, 0.5, 0.15) # очередные: яркий натриевый
const GATE_DONE := Color(0.45, 0.85, 0.35) # пройдены: зелёные
# Угол рыскания из данных (0 = вдоль -Z, -90 = на +X) — это ровно rotation.y в Godot
func gate_yaw(i: int) -> float:
return deg_to_rad(Data.CHECKPOINTS[i][2])
func gate_pos(i: int) -> Vector3:
return Data.CHECKPOINTS[i][0]
func gate_half(i: int) -> float:
return Data.CHECKPOINTS[i][1]
func _add_gate(i: int) -> void:
var last: bool = i == Data.CHECKPOINTS.size() - 1
var half: float = gate_half(i)
var mat := _emissive(GATE_OFF, 0.9)
var sheet := _emissive(GATE_OFF, 0.3, 0.07)
var root := Node3D.new()
root.position = gate_pos(i) - Vector3(0, FLOOR_TO_CENTER, 0) # в данных — центр капсулы, ворота ставим на пол
root.rotation.y = gate_yaw(i) # локальный -Z ворот смотрит по трассе
add_child(root)
var post_h := GATE_HEIGHT
for s in [-1.0, 1.0]:
var bm := BoxMesh.new()
bm.size = Vector3(0.4, post_h, 0.4)
root.add_child(_part(bm, mat, Vector3(s * half, post_h * 0.5, 0)))
var top := BoxMesh.new()
top.size = Vector3(half * 2 + 0.4, 0.4, 0.4)
root.add_child(_part(top, mat, Vector3(0, post_h, 0)))
var qm := QuadMesh.new() # слабая светящаяся «плёнка» в проёме
qm.size = Vector2(half * 2, post_h)
root.add_child(_part(qm, sheet, Vector3(0, post_h * 0.5, 0)))
var label := Label3D.new()
label.text = "FINISH" if last else "CHECKPOINT %d/%d" % [i + 1, Data.CHECKPOINTS.size()]
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.pixel_size = 0.016
label.modulate = GATE_OFF
label.outline_modulate = Color.BLACK
label.position = Vector3(0, post_h + 1.2, 0)
root.add_child(label)
_gates.append({"root": root, "mat": mat, "sheet": sheet, "label": label})
# Раскраска ворот по прогрессу игрока: пройденные зелёные, очередные яркие, остальные тусклые
func set_gate_progress(passed: int) -> void:
for i in _gates.size():
var g: Dictionary = _gates[i]
var c := GATE_DONE if i < passed else (GATE_NEXT if i == passed else GATE_OFF)
g.mat.emission = c
g.sheet.emission = c
g.label.modulate = c
g.mat.emission_energy_multiplier = 1.6 if i == passed else 0.9
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
# Ливень вокруг игрока: тонкие вертикальные штрихи, летящие по ветру; в ангаре (HALL_* из race_data.gd) не идёт
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.x > Data.HALL_X0 - 2.0 and p.x < Data.HALL_X1 + 2.0 and p.z > Data.HALL_Z0 - 2.0 and p.z < Data.HALL_Z1 + 2.0 and p.y > Data.HALL_FLOOR - 2.0 and p.y < Data.HALL_CEIL + 1.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)
# Песня гонки: Ogg Vorbis, моно 32 кГц (~0,6 МБ). Включается в момент «СТАРТ!» (race_rules.gd), плавно затихает при возврате в ожидание
const MUSIC_DB := -9.0
var _music: AudioStreamPlayer
var _music_tween: Tween
func set_music(on: bool) -> void:
if _music == null:
var stream := load("res://maps/race/audio/music.ogg") as AudioStreamOggVorbis
stream.loop = true
_music = AudioStreamPlayer.new()
_music.stream = stream
_music.volume_db = MUSIC_DB
add_child(_music)
if _music_tween:
_music_tween.kill()
if on:
if not _music.playing:
_music.volume_db = MUSIC_DB
_music.play() # каждый круг — с начала
elif _music.playing:
_music_tween = create_tween()
_music_tween.tween_property(_music, "volume_db", -60.0, 0.8)
_music_tween.tween_callback(_music.stop)