Карта 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:
@@ -7,6 +7,7 @@ const PORTALS := [
|
||||
{"title": "SANDBOX", "map": "world", "scene": "res://maps/sandbox/sandbox.tscn", "pack": "world.pck", "color": Color(0.2, 0.9, 1.0)},
|
||||
{"title": "BACKROOMS", "map": "backrooms", "scene": "res://maps/backrooms/backrooms.tscn", "pack": "backrooms.pck", "color": Color(1.0, 0.85, 0.25)},
|
||||
{"title": "PARKOUR", "map": "parkour", "scene": "res://maps/parkour/parkour.tscn", "pack": "parkour.pck", "color": Color(1.0, 0.2, 0.65)},
|
||||
{"title": "RACE", "map": "race", "scene": "res://maps/race/race.tscn", "pack": "race.pck", "color": Color(1.0, 0.55, 0.12)},
|
||||
]
|
||||
|
||||
const ROOM_WIDTH := 28.0
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn0bcs7hv627b
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c18dfdsjr2uje
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dowlbtftiudoh
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b67m05gbpouwi
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
@@ -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
|
||||
Reference in New Issue
Block a user