Карта 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
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://bcwtlavc5apcb"
path="res://.godot/imported/music.ogg-478941bdb69023107f4668af82e2cd34.oggvorbisstr"
[deps]
source_file="res://maps/race/audio/music.ogg"
dest_files=["res://.godot/imported/music.ogg-478941bdb69023107f4668af82e2cd34.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4
+42
View File
@@ -0,0 +1,42 @@
shader_type spatial;
// Поверхности уровня Parkour: освещение запечено в цвета вершин (Blender, Cycles), динамических источников нет.
// Итог = цвет материала × запечённый свет × мелкое «зерно» бетона (по мировым координатам) + слабый блик на скользящих углах.
render_mode unshaded;
uniform vec3 albedo_color = vec3(0.1);
uniform float light_scale = 8.0; // цвета вершин сжаты в 8 раз при экспорте (см. bake.py)
uniform float exposure = 1.0;
uniform sampler2D grain : repeat_enable, filter_linear_mipmap;
uniform float grain_scale = 0.3;
uniform float grain_amount = 0.45;
uniform sampler2D sky_tex : source_color, filter_linear_mipmap; // панорама неба: отражается в «мокрых» горизонтальных поверхностях
uniform float wet = 0.6;
varying vec3 wpos;
varying vec3 wnorm;
void vertex() {
wpos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
wnorm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
}
vec2 pano_uv(vec3 d) {
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
}
void fragment() {
vec3 n = abs(wnorm);
n /= (n.x + n.y + n.z + 0.0001);
float g = texture(grain, wpos.zy * grain_scale).r * n.x
+ texture(grain, wpos.xz * grain_scale).r * n.y
+ texture(grain, wpos.xy * grain_scale).r * n.z;
float gr = mix(1.0 - grain_amount * 0.5, 1.0 + grain_amount * 0.5, g);
vec3 lit = COLOR.rgb * light_scale;
vec3 nv = normalize(NORMAL);
float ndv = clamp(dot(nv, normalize(VIEW)), 0.0, 1.0);
float fres = 0.05 + 0.95 * pow(1.0 - ndv, 4.0);
vec3 rw = normalize((INV_VIEW_MATRIX * vec4(reflect(-normalize(VIEW), nv), 0.0)).xyz);
float up = smoothstep(0.6, 0.95, wnorm.y); // блестят только горизонтальные поверхности
vec3 refl = textureLod(sky_tex, pano_uv(rw), 3.0).rgb;
ALBEDO = albedo_color * lit * gr * exposure + refl * fres * wet * up * (0.5 + 0.5 * min(lit.r + lit.g + lit.b, 1.0));
}
+1
View File
@@ -0,0 +1 @@
uid://dn0bcs7hv627b
+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)
+1
View File
@@ -0,0 +1 @@
uid://c18dfdsjr2uje
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c6jjwrw7lfse7"
path="res://.godot/imported/race.glb-32a8595cf9a31ef83d00b04c51aac272.scn"
[deps]
source_file="res://maps/race/race.glb"
dest_files=["res://.godot/imported/race.glb-32a8595cf9a31ef83d00b04c51aac272.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+12
View File
@@ -0,0 +1,12 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://maps/race/race.gd" id="1_level"]
[ext_resource type="PackedScene" path="res://maps/race/race.glb" id="2_map"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="3_player"]
[node name="Race" type="Node3D"]
script = ExtResource("1_level")
[node name="Map" parent="." instance=ExtResource("2_map")]
[node name="Player" parent="." instance=ExtResource("3_player")]
+48
View File
@@ -0,0 +1,48 @@
# Сгенерировано Blender models/race_01/build_race.py из layout.py — не править руками
extends RefCounted
# Старт: 8 слотов в ряд поперёк трассы, лицом по трассе (-Z); центр капсулы
const START_SLOTS := [
Vector3(-9.80, 1.25, -28.00),
Vector3(-7.00, 1.25, -28.00),
Vector3(-4.20, 1.25, -28.00),
Vector3(-1.40, 1.25, -28.00),
Vector3(1.40, 1.25, -28.00),
Vector3(4.20, 1.25, -28.00),
Vector3(7.00, 1.25, -28.00),
Vector3(9.80, 1.25, -28.00),
]
# Ворота по порядку: [центр капсулы на полу ворот, полуширина поперёк трассы (м), рыскание (град; 0 = -Z, -90 = на +X)]. Последний = ФИНИШ
# На развилках ворота стоят только в общих местах (после слияния веток), поэтому между ними бывает до ~170 м.
const CHECKPOINTS := [
[Vector3(0.00, 1.20, -78.00), 13.0, 0.0], # cp1
[Vector3(0.00, 5.70, -114.00), 8.0, 0.0], # cp2
[Vector3(66.00, 5.70, -195.00), 12.0, -90.0], # cp3
[Vector3(99.00, 6.20, -195.00), 13.0, -90.0], # cp4
[Vector3(150.00, 4.70, -195.00), 13.0, -90.0], # cp5
[Vector3(163.00, 0.40, -306.00), 10.0, 0.0], # cp6
[Vector3(162.00, -5.70, -362.00), 12.0, 0.0], # cp7
[Vector3(161.00, -5.70, -440.00), 28.0, 0.0], # cp8
]
# Лазеры: центр, размер, режим (0 — всегда, 1 — пульсирует), период, время включения, сдвиг
const LASERS := [
[Vector3(170.50, -6.45, -385.25), Vector3(5.00, 0.90, 0.50), 0, 0.00, 0.00, 0.00], # t_floor
[Vector3(170.50, -5.30, -397.25), Vector3(5.00, 0.60, 0.50), 1, 2.60, 1.30, 0.00], # t_mid
]
# Ангар (для отключения дождя): x0, x1, y0(+Z), y1, пол, потолок; в координатах Godot по Z — расстояние по трассе со знаком минус
const HALL_X0 := 148.0
const HALL_X1 := 173.0
const HALL_Z0 := -416.0
const HALL_Z1 := -364.0
const HALL_FLOOR := -6.9
const HALL_CEIL := 2.1
# Портал в хаб за финишем: позиция на полу площадки
const PORTAL := Vector3(161.00, -6.90, -490.00)
# Длина безопасного маршрута, м
const TRACK_LENGTH := 632.9
# Срыв: на столько ниже последнего чекпоинта — возрождение
const KILL_DEPTH := 32.0
+1
View File
@@ -0,0 +1 @@
uid://dowlbtftiudoh
+615
View File
@@ -0,0 +1,615 @@
extends Node
# Правила режима «Гонка»: ожидание -> отсчёт -> гонка -> итоги -> снова ожидание, HUD и обмен событиями.
# Сервер про гонку ничего не знает, он только пересылает события (Net.RACE_*, submit_race/recv_race) внутри комнаты карты.
# Каждый клиент ведёт собственную копию состояния и приходит к одному результату, потому что:
# - старт даёт единственный отправитель (наименьший id в комнате) и все считают время от одного момента;
# - ворота засчитывает сам игрок и сообщает всем (CP/FINISH), порядок мест вычисляется одинаково у всех;
# - раз в секунду каждый шлёт «маяк» (RACE_STATE) — по нему догоняют потерявшие событие и пришедшие позже.
# Биты маяка (a): 0..7 — пройдено ворот; 8 — готов; 9..10 — фаза отправителя (Phase).
const Data = preload("res://maps/race/race_data.gd")
enum Phase { WAIT, COUNTDOWN, RACE, RESULTS }
const COUNTDOWN_TIME := 6.0 # от START до старта: секунда на «приготовьтесь» и 5…1
const CLOCK_TOLERANCE := 0.4 # на сколько unix-время старта может разойтись с ожидаемым, прежде чем считаем часы чужими
const MAX_SLOTS := 8
const EXTRA_ROW := 3.0 # запасные места: ряды позади стартовых слотов (на столько метров назад)
const AFTER_FIRST_FINISH := 45.0 # гонка кончается через столько секунд после первого финиша
const RESULTS_TIME := 8.0
const BEACON_PERIOD := 1.0
const GATE_HEIGHT := 5.0 # диапазон высот, в котором пересечение плоскости ворот засчитывается
const GATE_MARGIN := 1.0 # запас по ширине сверх полуширины ворот
const TELEPORT_JUMP := 4.0 # смещение за кадр больше этого — телепорт (возрождение), не пересечение ворот
var map: Node3D # race.gd: gate_pos/gate_yaw/gate_half/set_gate_progress
var player: Node3D
var phase := Phase.WAIT
var my_id := 1
var flags_ready := {} # id -> готов (только в ожидании)
var passed := {} # id -> пройдено ворот (участники круга)
var finish := {} # id -> время финиша, с
var peer_phase := {} # id -> фаза по последнему маяку
var known := {} # id -> true: этого игрока видели в комнате
var participants: Array = [] # отсортированные id участников текущего круга
var start_tick := 0 # Time.get_ticks_msec() момента старта
var start_sender := 0
var results: Array = [] # итоги круга: {id, place, time, gates}
var results_end_tick := 0
var splits: Array = [] # свои сплиты круга
var best_time := -1.0
var best_splits: Array = []
var debug := false # --race-log: факты в консоль (тесты)
var _beacon_timer := 0.0
var _prev_pos := Vector3.ZERO
var _have_prev := false
var _go_until := 0
var _split_until := 0
var _new_record := false
var _music_on := false
# HUD
var _timer_label: Label
var _best_label: Label
var _place_label: Label
var _gates_label: Label
var _center_label: Label
var _split_label: Label
var _hint_label: Label
var _results_panel: PanelContainer
func setup(m: Node3D) -> void:
map = m
player = m.player
func _ready() -> void:
debug = OS.get_cmdline_user_args().has("--race-log")
my_id = _current_id()
known[my_id] = true
_load_best()
_build_hud()
Net.race_event.connect(_on_race_event)
map.set_gate_progress(0)
_log("карта готова, id=%d, ворот=%d" % [my_id, Data.CHECKPOINTS.size()])
func _log(text: String) -> void:
if debug:
print("[RACE %d] %s" % [my_id, text])
# ---------- участники ----------
func _current_id() -> int:
return multiplayer.get_unique_id() if Net.connected else 1
# Все игроки комнаты, включая нас (чужие — по призракам Net.ghosts)
func _room_ids() -> Array:
var ids: Array = [my_id]
for id in Net.ghosts.keys():
if is_instance_valid(Net.ghosts[id]):
ids.append(id)
ids.sort()
return ids
func _refresh_room() -> void:
var now := _current_id()
if now != my_id: # подключились к серверу: у нас другой id — начинаем с чистого листа
my_id = now
_reset_all()
var ids := _room_ids()
for id in ids:
known[id] = true
for id in known.keys():
if not ids.has(id):
_forget(id)
func _reset_all() -> void:
flags_ready.clear()
passed.clear()
finish.clear()
peer_phase.clear()
known.clear()
known[my_id] = true
participants.clear()
_set_phase(Phase.WAIT)
player.input_frozen = false
map.set_gate_progress(0)
# Игрок вышел: убираем из расчётов
func _forget(id: int) -> void:
known.erase(id)
flags_ready.erase(id)
passed.erase(id)
finish.erase(id)
peer_phase.erase(id)
participants.erase(id)
_log("игрок %d вышел" % id)
func _name_of(id: int) -> String:
return "Игрок %d" % (id % 1000) # как у призрака (net/ghost.gd)
func _color_of(id: int) -> Color:
return Color.from_hsv(fmod(id * 0.618034, 1.0), 0.7, 1.0) # как у призрака
func _pos_of(id: int) -> Vector3:
if id == my_id:
return player.global_position
var g = Net.ghosts.get(id)
if g != null and is_instance_valid(g):
return g.position
return Vector3(1e6, 1e6, 1e6)
# ---------- сеть ----------
func _send(kind: int, a: int, b: float) -> void:
Net.send_race(kind, a, b)
func _beacon_bits() -> int:
var bits := mini(passed.get(my_id, 0), 255)
if flags_ready.get(my_id, false):
bits |= 1 << 8
bits |= int(phase) << 9
return bits
func _send_beacon() -> void:
_beacon_timer = 0.0
_send(Net.RACE_STATE, _beacon_bits(), finish.get(my_id, -1.0))
func _set_phase(p: Phase) -> void:
if p != phase:
phase = p
_log("фаза -> %s" % Phase.keys()[p])
_send_beacon()
func _on_race_event(id: int, kind: int, a: int, b: float) -> void:
if id == my_id:
return
match kind:
Net.RACE_READY:
if phase == Phase.WAIT:
flags_ready[id] = a != 0
Net.RACE_START:
_on_start(id, b)
Net.RACE_CP:
if participants.has(id):
passed[id] = maxi(passed.get(id, 0), a + 1)
Net.RACE_FINISH:
if participants.has(id):
finish[id] = b
passed[id] = Data.CHECKPOINTS.size()
Net.RACE_STATE:
_on_beacon(id, a, b)
func _on_beacon(id: int, a: int, b: float) -> void:
var ph := (a >> 9) & 3
peer_phase[id] = ph
if phase == Phase.WAIT and ph == Phase.WAIT:
flags_ready[id] = ((a >> 8) & 1) == 1
if participants.has(id) and (phase == Phase.RACE or phase == Phase.RESULTS) and (ph == Phase.RACE or ph == Phase.RESULTS):
passed[id] = maxi(passed.get(id, 0), a & 255)
if b >= 0.0 and not finish.has(id):
finish[id] = b
# Идёт чужой круг, в который мы не попали (зашли позже): бегаем свободно и ждём следующего
func waiting_for_race() -> bool:
if phase != Phase.WAIT:
return false
for id in peer_phase:
if known.has(id) and (peer_phase[id] == Phase.COUNTDOWN or peer_phase[id] == Phase.RACE):
return true
return false
# ---------- ожидание: готовность ----------
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ready") and not event.is_echo():
toggle_ready()
func toggle_ready() -> void:
if phase != Phase.WAIT or waiting_for_race():
return
flags_ready[my_id] = not flags_ready.get(my_id, false)
_log("готов=%s" % flags_ready[my_id])
_send(Net.RACE_READY, 1 if flags_ready[my_id] else 0, 0.0)
# Все ли в комнате готовы; старт даёт наименьший id (если двое стали «последними готовыми», старт всё равно один)
func _check_all_ready() -> void:
if phase != Phase.WAIT or waiting_for_race() or not flags_ready.get(my_id, false):
return
var ids := _room_ids()
if ids[0] != my_id:
return
for id in ids:
if not flags_ready.get(id, false):
return
var t := Time.get_unix_time_from_system() + COUNTDOWN_TIME
_log("все готовы (%d), даю старт" % ids.size())
_send(Net.RACE_START, 0, t)
_on_start(my_id, t)
# ---------- отсчёт и старт ----------
func _slot_pos(i: int) -> Vector3:
var base: Vector3 = Data.START_SLOTS[i % MAX_SLOTS]
return base + Vector3(0, 0, EXTRA_ROW * (i / MAX_SLOTS)) # лишние — рядами позади
func _on_start(sender: int, unix_start: float) -> void:
if phase == Phase.RACE or (phase == Phase.COUNTDOWN and sender >= start_sender):
return # второй START (двое решили одновременно): остаётся от наименьшего id
start_sender = sender
var remaining := COUNTDOWN_TIME
if sender != my_id:
remaining = unix_start - Time.get_unix_time_from_system()
if absf(remaining - COUNTDOWN_TIME) > CLOCK_TOLERANCE: # часы отправителя расходятся с нашими: считаем от получения
_log("часы расходятся на %.2f с, считаю от получения" % (remaining - COUNTDOWN_TIME))
remaining = COUNTDOWN_TIME - 0.1
start_tick = Time.get_ticks_msec() + int(remaining * 1000.0)
flags_ready.clear()
participants = _room_ids()
passed.clear()
finish.clear()
splits.clear()
_new_record = false
for id in participants:
passed[id] = 0
var slot := participants.find(my_id)
player.place(Transform3D(Basis.IDENTITY, _slot_pos(slot)))
player.input_frozen = true
_have_prev = false
map.set_gate_progress(0)
_set_phase(Phase.COUNTDOWN)
_log("старт от %d: слот %d из %d, через %.2f с, участники %s" % [sender, slot, participants.size(), remaining, participants])
func _begin_race() -> void:
player.input_frozen = false
_go_until = Time.get_ticks_msec() + 1200
_set_phase(Phase.RACE)
func race_time() -> float:
return (Time.get_ticks_msec() - start_tick) / 1000.0
# ---------- гонка ----------
func _physics_process(_delta: float) -> void:
if phase != Phase.RACE or not participants.has(my_id) or finish.has(my_id) or player.is_dead:
_have_prev = false
return
var cur: Vector3 = player.global_position
if _have_prev:
var idx: int = passed.get(my_id, 0)
if (cur - _prev_pos).length() < TELEPORT_JUMP and _crossed(idx, _prev_pos, cur):
_pass_gate(idx)
_prev_pos = cur
_have_prev = true
# Отрезок prev->cur пересёк плоскость ворот i по ходу трассы в пределах ворот?
func _crossed(i: int, prev: Vector3, cur: Vector3) -> bool:
var p: Vector3 = map.gate_pos(i)
var yaw: float = map.gate_yaw(i)
var fwd := Vector3(-sin(yaw), 0, -cos(yaw)) # rotation.y = yaw поворачивает -Z именно сюда
var side := Vector3(cos(yaw), 0, -sin(yaw))
var d0 := (prev - p).dot(fwd)
var d1 := (cur - p).dot(fwd)
if not (d0 < 0.0 and d1 >= 0.0):
return false
var q := prev.lerp(cur, d0 / (d0 - d1))
if absf((q - p).dot(side)) > float(map.gate_half(i)) + GATE_MARGIN:
return false
var dy := q.y - p.y
return dy > -3.0 and dy < GATE_HEIGHT + 4.0
func _pass_gate(idx: int) -> void:
var t := race_time()
var n: int = Data.CHECKPOINTS.size()
passed[my_id] = idx + 1
splits.append(t)
map.set_gate_progress(idx + 1)
# возрождаемся на последних пройденных воротах, лицом по трассе
player.spawn_transform = Transform3D(Basis(Vector3.UP, map.gate_yaw(idx)), map.gate_pos(idx))
var diff := ""
if best_splits.size() > idx:
var d: float = t - best_splits[idx]
diff = " %s%.2f" % ["+" if d >= 0.0 else "-", absf(d)]
_split_label.add_theme_color_override("font_color", Color(0.9, 0.45, 0.35) if d >= 0.0 else Color(0.5, 0.85, 0.4))
else:
_split_label.add_theme_color_override("font_color", Color(0.92, 0.88, 0.8))
if idx == n - 1:
finish[my_id] = t
_send(Net.RACE_FINISH, 0, t)
_new_record = best_time < 0.0 or t < best_time
if _new_record:
best_time = t
best_splits = splits.duplicate()
_save_best()
_split_label.text = "ФИНИШ %s%s%s" % [_fmt(t), diff, " НОВЫЙ РЕКОРД" if _new_record else ""]
_log("ФИНИШ %.3f (место %d)" % [t, _my_place()])
else:
_split_label.text = "ВОРОТА %d %s%s" % [idx + 1, _fmt(t), diff]
_send(Net.RACE_CP, idx, t)
_log("ворота %d: %.3f" % [idx + 1, t])
_split_until = Time.get_ticks_msec() + 3500
_send_beacon()
# ---------- места ----------
func _dist_next(id: int) -> float:
var g: int = passed.get(id, 0)
if g >= Data.CHECKPOINTS.size():
return 0.0
return _pos_of(id).distance_to(map.gate_pos(g))
# Финишировавшие по времени, затем остальные: больше ворот, ближе к следующим
func ranking() -> Array:
var ids: Array = participants.duplicate()
ids.sort_custom(func(a: int, b: int) -> bool:
var fa := finish.has(a)
var fb := finish.has(b)
if fa and fb:
return finish[a] < finish[b] or (finish[a] == finish[b] and a < b)
if fa != fb:
return fa
var pa: int = passed.get(a, 0)
var pb: int = passed.get(b, 0)
if pa != pb:
return pa > pb
var da := _dist_next(a)
var db := _dist_next(b)
if not is_equal_approx(da, db):
return da < db
return a < b)
return ids
func _my_place() -> int:
return ranking().find(my_id) + 1
func _check_race_end() -> void:
if participants.is_empty():
return
var first := -1.0 # время первого финиша
var all_done := true
for id in participants:
if finish.has(id):
if first < 0.0 or finish[id] < first:
first = finish[id]
else:
all_done = false
if all_done or (first >= 0.0 and race_time() >= first + AFTER_FIRST_FINISH):
_end_race()
func _end_race() -> void:
results.clear()
var rank := ranking()
for i in rank.size():
var id: int = rank[i]
results.append({"id": id, "place": i + 1, "time": finish.get(id, -1.0), "gates": passed.get(id, 0)})
results_end_tick = Time.get_ticks_msec() + int(RESULTS_TIME * 1000.0)
_set_phase(Phase.RESULTS)
_show_results()
for r in results:
_log("итог: место %d игрок %d время %.3f ворот %d" % [r.place, r.id, r.time, r.gates])
func _back_to_wait() -> void:
participants.clear()
passed.clear()
finish.clear()
flags_ready.clear()
_results_panel.visible = false
player.input_frozen = false
player.spawn_transform = Transform3D(Basis.IDENTITY, Data.START_SLOTS[0])
map.set_gate_progress(0)
_have_prev = false
_set_phase(Phase.WAIT)
func _process(delta: float) -> void:
_refresh_room()
_beacon_timer += delta
if _beacon_timer >= BEACON_PERIOD:
_send_beacon()
var now := Time.get_ticks_msec()
match phase:
Phase.WAIT:
_check_all_ready()
Phase.COUNTDOWN:
if now >= start_tick:
_begin_race()
Phase.RACE:
_check_race_end()
Phase.RESULTS:
if now >= results_end_tick:
_back_to_wait()
_update_hud()
# музыка: от «СТАРТ!» до возврата в ожидание; зашедший в разгар чужого круга тоже слышит её
var want := phase == Phase.RACE or phase == Phase.RESULTS
if phase == Phase.WAIT:
for id in peer_phase:
if known.has(id) and peer_phase[id] == Phase.RACE:
want = true
if want != _music_on:
_music_on = want
map.set_music(want)
# ---------- личный рекорд ----------
func _best_path() -> String:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--race-best="): # для тестов: не трогаем настоящий рекорд
return arg.substr("--race-best=".length())
return "user://race_best.cfg"
func _load_best() -> void:
var cf := ConfigFile.new()
if cf.load(_best_path()) == OK:
best_time = cf.get_value("race", "best", -1.0)
best_splits = cf.get_value("race", "splits", [])
func _save_best() -> void:
var cf := ConfigFile.new()
cf.set_value("race", "best", best_time)
cf.set_value("race", "splits", best_splits)
cf.save(_best_path())
# ---------- HUD ----------
const TEXT := Color(0.92, 0.88, 0.8)
const DIM := Color(0.66, 0.64, 0.6)
func _fmt(t: float) -> String:
t = maxf(t, 0.0)
return "%d:%05.2f" % [int(t / 60.0), fmod(t, 60.0)]
func _label(size: int, color: Color, parent: Control) -> Label:
var l := Label.new()
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", color)
l.add_theme_constant_override("outline_size", maxi(size / 5, 4))
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
parent.add_child(l)
return l
func _build_hud() -> void:
var layer := CanvasLayer.new()
layer.layer = 12
add_child(layer)
var root := Control.new()
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(root)
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_timer_label = _label(40, TEXT, root)
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_best_label = _label(20, DIM, root)
_best_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_place_label = _label(30, TEXT, root)
_gates_label = _label(22, DIM, root)
_center_label = _label(160, TEXT, root)
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_split_label = _label(28, TEXT, root)
_split_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_hint_label = _label(24, TEXT, root)
_hint_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timer_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP, Control.PRESET_MODE_MINSIZE, 0)
for l in [_timer_label, _best_label, _split_label, _hint_label]:
l.anchor_left = 0.0
l.anchor_right = 1.0
l.offset_left = 0.0
l.offset_right = 0.0
_timer_label.anchor_top = 0.0
_timer_label.offset_top = 14.0
_best_label.anchor_top = 0.0
_best_label.offset_top = 64.0
_split_label.anchor_top = 0.0
_split_label.offset_top = 100.0
_hint_label.anchor_top = 1.0
_hint_label.anchor_bottom = 1.0
_hint_label.offset_top = -80.0
_hint_label.offset_bottom = -40.0
_place_label.position = Vector2(64, 18)
_gates_label.position = Vector2(66, 58)
_center_label.anchor_left = 0.0
_center_label.anchor_right = 1.0
_center_label.anchor_top = 0.2
_center_label.anchor_bottom = 0.5
_center_label.offset_left = 0.0
_center_label.offset_right = 0.0
_results_panel = PanelContainer.new()
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.04, 0.045, 0.05, 0.84)
sb.border_color = Color(0.45, 0.30, 0.16, 0.9)
sb.set_border_width_all(2)
sb.set_content_margin_all(26)
_results_panel.add_theme_stylebox_override("panel", sb)
_results_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_results_panel.visible = false
root.add_child(_results_panel)
_results_panel.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
_results_panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
_results_panel.grow_vertical = Control.GROW_DIRECTION_BOTH
func _show_results() -> void:
for c in _results_panel.get_children():
c.queue_free()
var box := VBoxContainer.new()
box.add_theme_constant_override("separation", 10)
_results_panel.add_child(box)
var title := _label(36, TEXT, box)
title.text = "ИТОГИ"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
box.add_child(grid)
for r in results:
var c := _color_of(r.id)
var mine: bool = r.id == my_id
var place := _label(26, c if mine else TEXT, grid)
place.text = "%d" % r.place
var nm := _label(26, c, grid)
nm.text = _name_of(r.id) + (" (вы)" if mine else "")
var tm := _label(26, TEXT if r.time >= 0.0 else DIM, grid)
tm.text = _fmt(r.time) if r.time >= 0.0 else "не финишировал (%d/%d)" % [r.gates, Data.CHECKPOINTS.size()]
if best_time >= 0.0:
var rec := _label(20, DIM, box)
rec.text = "Личный рекорд: %s%s" % [_fmt(best_time), " (новый!)" if _new_record else ""]
rec.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_results_panel.visible = true
func _update_hud() -> void:
var now := Time.get_ticks_msec()
var racing := participants.has(my_id) and (phase == Phase.RACE or phase == Phase.RESULTS)
_timer_label.visible = racing
_place_label.visible = racing
_gates_label.visible = racing
_best_label.visible = racing or phase == Phase.COUNTDOWN
_best_label.text = "РЕКОРД %s" % (_fmt(best_time) if best_time >= 0.0 else "—")
_split_label.visible = now < _split_until and racing
if racing:
var n: int = Data.CHECKPOINTS.size()
if finish.has(my_id):
_timer_label.text = _fmt(finish[my_id])
_timer_label.add_theme_color_override("font_color", Color(0.5, 0.85, 0.4))
else:
_timer_label.text = _fmt(race_time())
_timer_label.add_theme_color_override("font_color", TEXT)
_place_label.text = "МЕСТО %d/%d" % [_my_place(), participants.size()]
_gates_label.text = "ВОРОТА %d/%d" % [passed.get(my_id, 0), n]
# центр: отсчёт, «СТАРТ!»
_center_label.visible = false
if phase == Phase.COUNTDOWN:
var rem := (start_tick - now) / 1000.0
_center_label.visible = true
if rem > COUNTDOWN_TIME - 1.0:
_center_label.add_theme_font_size_override("font_size", 56)
_center_label.text = "ПРИГОТОВЬТЕСЬ"
else:
_center_label.add_theme_font_size_override("font_size", 170)
_center_label.text = "%d" % maxi(ceili(rem), 1)
elif phase == Phase.RACE and now < _go_until:
_center_label.visible = true
_center_label.add_theme_font_size_override("font_size", 110)
_center_label.text = "СТАРТ!"
# подсказка внизу
_hint_label.visible = true
if phase == Phase.WAIT:
if waiting_for_race():
_hint_label.text = "ГОНКА ИДЁТ — дождитесь следующего круга"
else:
var ids := _room_ids()
var n_ready := 0
for id in ids:
if flags_ready.get(id, false):
n_ready += 1
if flags_ready.get(my_id, false):
_hint_label.text = "ГОТОВ (%d/%d) · R — отменить" % [n_ready, ids.size()]
else:
_hint_label.text = "R — готов (%d/%d)" % [n_ready, ids.size()]
elif phase == Phase.RACE and not participants.has(my_id):
_hint_label.text = "ГОНКА ИДЁТ"
else:
_hint_label.visible = false
+1
View File
@@ -0,0 +1 @@
uid://b67m05gbpouwi
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+41
View File
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dihjhoubptq2c"
path.s3tc="res://.godot/imported/sky.png-7a54d5019f47e83829b0ee441cdb6eec.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://maps/race/sky.png"
dest_files=["res://.godot/imported/sky.png-7a54d5019f47e83829b0ee441cdb6eec.s3tc.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1