diff --git a/CHANGELOG.md b/CHANGELOG.md index e679896..8f7bb09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.61.0-test3 — Rings, dots and beam rain + +- LED rings under the rear truss and on the back towers: a full circle that swells on every kick. +- A grid of dots on the far wall that flashes on the melody in quiet moments and breaks. +- Beam rain (Techno and Hard): the rear truss and the delay towers' beams each fall from 45° up to 45° down in + about two and a half seconds, every beam on its own moment, and go back up in the dark. The hall steps back. + ## 0.61.0-test2 — A generic club booth - The players and the mixer are generic club gear in the familiar layout, without real brand or model names. diff --git a/project.godot b/project.godot index 90eb89a..39d4b69 100644 --- a/project.godot +++ b/project.godot @@ -5,7 +5,7 @@ config_version=5 [application] config/name="Stage Visualizer" -config/version="0.61.0-test2" +config/version="0.61.0-test3" run/main_scene="res://main.tscn" config/features=PackedStringArray("4.7", "Forward Plus") diff --git a/scripts/rig.gd b/scripts/rig.gd index b900631..cde49ad 100644 --- a/scripts/rig.gd +++ b/scripts/rig.gd @@ -97,6 +97,17 @@ var laser_spread := 0.6 var laser_tilt := 0.0 var laser_pan := 0.0 var beam_strength := 1.0 +# Set pieces: LED rings that swell on the kick, and a grid of dots that flashes on the melody. The show sets their +# levels (0..1) and one colour; they light only themselves, so they cost no lights. +var ring_levels := PackedFloat32Array() +var dot_levels := PackedFloat32Array() +var ring_at: Array[Vector3] = [] +var dot_at: Array[Vector3] = [] +var piece_color := Color.WHITE +var _rings: MultiMesh +var _dots: MultiMesh +const DOT_COLUMNS := 18 +const DOT_ROWS := 7 var _blinder_glow := 0.0 var _by_kind := [[], [], []] var _mm := [] # per kind: {base, yoke, head, lens, beam} @@ -302,6 +313,7 @@ func build() -> void: fixtures[i].rt = randf() for k in 3: _mm.append(_kind_batches(k)) _batten_batches() + _set_pieces() if LASERS: _laser_batches() # the strobes light the hall through a few shared lamps for p in [Vector3(0, 9, Hall.BACK + 2), Vector3(0, 3, F - 0.5), Vector3(0, 10.5, -8), Vector3(0, 10, 22)]: @@ -497,6 +509,38 @@ func _yoke_mesh(r: float) -> ArrayMesh: st.append_from(b, 0, Transform3D(Basis.IDENTITY, part[1])) return st.commit() +func _set_pieces() -> void: + var facing_stage := Basis(Vector3.RIGHT, PI / 2.0) # a torus lies flat; stood up, it faces along Z + var xfs: Array[Transform3D] = [] + # under the rear truss, between its beams, facing the stage and the DJ + for k in 8: + ring_at.append(Vector3(-15.75 + k * 4.5, 9.75, 26.3)) + # on the back towers, below their beams, facing the hall + for k in 8: + ring_at.append(Vector3(-21.0 + k * 6.0, 4.4, Hall.BACK + 1.35)) + for at in ring_at: xfs.append(Transform3D(facing_stage, at)) + var torus := TorusMesh.new() + torus.inner_radius = 0.5 + torus.outer_radius = 0.6 + torus.rings = 48 + torus.ring_segments = 8 + var material := Kit.shader("res://shaders/batten_pixel.gdshader", {"gain": 6.0}) + _rings = _multimesh(torus, ring_at.size(), material, true, false, false) + for i in xfs.size(): _rings.set_instance_transform(i, xfs[i]) + # a grid of dots on the far wall, over the crowd, seen from the booth + for r in DOT_ROWS: + for c in DOT_COLUMNS: + dot_at.append(Vector3(-20.4 + c * 2.4, 4.0 + r * 1.35, Hall.D / 2.0 - 0.35)) + var dot := SphereMesh.new() + dot.radius = 0.14 + dot.height = 0.28 + dot.radial_segments = 10 + dot.rings = 5 + _dots = _multimesh(dot, dot_at.size(), material, true, false, false) + for i in dot_at.size(): _dots.set_instance_transform(i, Transform3D(Basis.IDENTITY, dot_at[i])) + ring_levels.resize(ring_at.size()) + dot_levels.resize(dot_at.size()) + func _batten_batches() -> void: var body := BoxMesh.new() body.size = Vector3(1.03, 0.13, 0.16) @@ -628,6 +672,12 @@ func update(dt: float, beat: float) -> void: for pixel in Batten.PIXELS: var pixel_color: Color = b.pixel_colors[pixel] * b.pixel_levels[pixel] * b.level _batten_pixels.multimesh.set_instance_color(i * Batten.PIXELS + pixel, Color(pixel_color.r, pixel_color.g, pixel_color.b, 1.0)) + for i in ring_at.size(): + var c := piece_color * ring_levels[i] + _rings.set_instance_color(i, Color(c.r, c.g, c.b, 1.0)) + for i in dot_at.size(): + var c := piece_color * dot_levels[i] + _dots.set_instance_color(i, Color(c.r, c.g, c.b, 1.0)) for o in _strobe_lights: o.visible = strobe > 0.02 o.light_energy = 16.0 * strobe diff --git a/scripts/show/director.gd b/scripts/show/director.gd index c7571e1..299a6ed 100644 --- a/scripts/show/director.gd +++ b/scripts/show/director.gd @@ -224,7 +224,10 @@ var phrasing := ShowPhrasing.new() var passage := ShowPassage.new() var _passage_seen := -1 # The groove: what the parts of the track do, each with its own place in the rig (see _groove). -var groove := {"clap": 0.0, "hats": 0.0, "body": 0.0, "bass": 0.0, "claps": 0, "hat_count": 0, "bodies": 0} +var groove := {"clap": 0.0, "hats": 0.0, "body": 0.0, "bass": 0.0, "claps": 0, "hat_count": 0, "bodies": 0, "leads": 0} +var _dot_flash := 0.0 +var _dot_seen := 0 +var _dot_pick := 0 var _grid_step := -1 var _frozen := {"m": 0.0, "spin": 0.0, "phase": 0.0} # where the movement stood when the passage began (techno holds it) var gestures := ShowGestures.new() @@ -851,6 +854,7 @@ func _direct(dt: float) -> void: var row: bool = h.role == "row" var role: String = "stage" if row else h.role var prole: String = passage.role(f.group) if passages and not row else "" + var rain: bool = passages and passage.figure == "rain" and f.group in ShowPassage.RAIN_GROUPS var a: Vector2 if h.role == "overhead": if brk: a = _toward(p, _break_target(h, p, k, s, sig) if multi else L.floor_target(h, p, k, _beat, sec, sig.build, sig.energy, sig.drop)) @@ -874,32 +878,33 @@ func _direct(dt: float) -> void: h.cue = cue var moving: bool = _t - h.move_at < 5.0 and gap > 0.2 h.move_level += ((0.2 if moving else 1.0) - h.move_level) * (e_moving if moving else e_settle) - if _t - h.move_at > 0.18: + if rain: h.aim = a # a falling beam follows its path itself; the motors still limit it + elif _t - h.move_at > 0.18: h.aim.x += pan_gap * e25 h.aim.y += (a.y - h.aim.y) * e25 f.target = p + _dir(h.aim) * 30.0 var I: float = I_base if prole == "" else I_still + kick_term * ShowPassage.PULSE[prole] / 0.55 var pg := 1.0 - if chase_gate and not row and role == "overhead" and prole in ["", "lead"]: + if chase_gate and not row and role == "overhead" and prole in ["", "lead"] and not rain: pg = 0.1 + 0.9 * pow(maxf(0.0, cos(h.u * TAU * 1.5 - sig.phase_clock * TAU / 4.0 + h.row * 1.1)), 3.0) - I = (I * (_row_w if row else _w[role] if prole == "" else ShowPassage.LEVEL[prole]) * pg + I_drop) * master + I = (I * (_row_w if row else _w[role] if prole == "" else passage.level(f.group, prole)) * pg + I_drop) * master # give the centre, the sides and the stage turns in the foreground; a drop opens the full rig var bank: int = h.bank if not brk and accent_bank == bank: I += I_accent # the genre's share of lit heads: the rest stay dark outside a drop - if not brk and not row and h.lit_key > lit_share and not (prole != "" and role == "stage"): I *= dropk * 0.9 + if not brk and not row and h.lit_key > lit_share and not (prole != "" and role == "stage") and not rain: I *= dropk * 0.9 var foreground: bool = bank == foreground_bank var lv: float = 1.0 if foreground or prole != "" else 0.28 if calm or brk else 0.45 h.level += (lv - h.level) * e18 I *= h.level + (1.0 - h.level) * dropk - if not (prole != "" and role == "stage"): I *= phrasing.gain(bank, f.mount, dropk) + if not (prole != "" and role == "stage") and not rain: I *= phrasing.gain(bank, f.mount, dropk) I *= 1.0 - _screen_focus * (0.6 if row or role == "stage" else 0.3) * (1.0 - dropk) # hold each figure for 32 kicks; builds leave a small group near the stage var build_on: bool = absf(h.xn) <= 1.0 - bld * 0.85 and (bld < 0.65 or bank == 2 or h.row % 3 == 0) var gate_name: String = look_gate if h.role == "overhead" else row_gate if row else "patterns" if safe and not row: gate_name = "all" # in a passage the figure owns the stage's selection, and only the lead plays the look's gate - if prole != "" and (role == "stage" or prole != "lead"): gate_name = "all" + if prole != "" and (role == "stage" or prole != "lead" or rain): gate_name = "all" var on: bool = build_on if brk else L.gate(gate_name, h, counted, pattern) if prole != "" and role == "stage": on = passage.stage_on(f.group, f.row) if row and dropk < 0.5: on = on and posmod(h.slot, 4) == posmod(floori(sig.phase_clock / 8.0) + _scene, 4) @@ -909,7 +914,8 @@ func _direct(dt: float) -> void: I *= h.move_level + (1.0 - h.move_level) * dropk # the lead's rhythm: light travels between its fixtures on the beat - if still and prole == "lead": I *= passage.chase(f.group, f.row, h.u, sig.phase_clock, sec) + (1.0 - passage.chase(f.group, f.row, h.u, sig.phase_clock, sec)) * dropk + if rain: I *= passage.rain_level(f.mount) + elif still and prole == "lead": I *= passage.chase(f.group, f.row, h.u, sig.phase_clock, sec) + (1.0 - passage.chase(f.group, f.row, h.u, sig.phase_clock, sec)) * dropk f.level = _lamp(I) * float(faders.get(f.group, 1.0)) # the look spreads the two colours, through a slow colour fade var cname: String = look_colour if h.role == "overhead" else row_colour if row else "sides" @@ -989,6 +995,24 @@ func _direct(dt: float) -> void: bt.chase_amount = (0.55 if safe else 0.85) * (1.0 - dropk) if gestures.name == "spatial_sequence" and gestures.mix > 0.5 and not calm and not brk else 0.0 bt.strobe = flash * strobe_k * master * float(tune.groups.get("batten", 1.0)) + # ----- set pieces: the rings swell on the kick, the dot grid flashes on the melody in a quiet moment ----- + var piece: Color = mono_colour(_colour_block() + _palette * 137) if multi and _mono_block() else col + rig.piece_color = col if single_colour else piece + var pieces: float = float(tune.groups.get("batten", 1.0)) + var swell: float = sig.build * 0.25 if brk else sig.kick * (0.3 if safe else 1.0) + for i in rig.ring_at.size(): + rig.ring_levels[i] = _lamp((0.05 + swell * 0.9 + sig.drop * 0.4) * master) * pieces + var melodic: bool = brk or sig.energy < 0.45 + if int(groove.leads) != _dot_seen: + _dot_seen = int(groove.leads) + if melodic: + _dot_flash = 1.0 + _dot_pick += 1 + _dot_flash *= exp(-dt * (4.0 if safe else 8.0)) + for i in rig.dot_at.size(): + var lit: bool = posmod(hash("%d:%d" % [i, _dot_pick]), 3) != 0 + rig.dot_levels[i] = _lamp((0.02 + (_dot_flash * (0.4 if safe else 1.0) if lit else 0.0) + sig.drop * 0.3) * master) * pieces + # ----- the trusses on their winches ----- # Heavy trusses hold a formation for minutes. In a break they sink further as the build grows, in a measured # wave, and level out; the drop takes them back up to the formation. @@ -1032,6 +1056,7 @@ func _groove(dt: float, grid: float, brk: bool) -> void: groove.claps = p.hits[4] groove.hat_count = p.hits[5] groove.bodies = p.hits[2] + groove.leads = p.hits[3] groove.bass = p.level[1] * p.present[1] if p.came_in[3] or p.came_in[2]: passage.open() elif not brk and _activity > 0.3: diff --git a/scripts/show/passage.gd b/scripts/show/passage.gd index daf0bd2..13f1241 100644 --- a/scripts/show/passage.gd +++ b/scripts/show/passage.gd @@ -15,7 +15,12 @@ const FIGURES := { "roof_cross": ["roof", ["back", "bars"]], # the roof spots weave: neighbours lean opposite ways, reaching out into the hall "roof_fan": ["roof", ["back"]], # a mirrored fan from the roof that widens and reaches further "overhead": ["hall", ["back"]], # the hall's own looks lead; the stage holds a quiet figure + "rain": ["rear", ["delay"]], # beam rain: every beam falls on its own from 45° up to 45° down } +# Beam rain: one fall takes RAIN_FALL of a cycle of RAIN_BEATS (about 2.5 s at 128 BPM); the beam goes back up dark. +const RAIN_BEATS := 8.0 +const RAIN_FALL := 0.7 +const RAIN_GROUPS := ["rear", "delay"] const LEVEL := {"lead": 1.0, "support": 0.6, "hold": 0.3} # How much of the kick a role may show: the lead breathes with it, the rest barely. const PULSE := {"lead": 0.75, "support": 0.3, "hold": 0.08} @@ -37,6 +42,7 @@ var stepped := false # techno: heads stand still and change position only i var route := "across" # the passage's rhythm: how the lead's light travels from fixture to fixture on the beat const ROUTES := ["across", "centre", "ladder", "alternate", "pingpong"] var _open := 0.0 +var _clock := 0.0 # the beat grid now, for figures that move on their own var _grid := 0.0 # a new layer (a lead) came in: the figure opens up, then settles func open() -> void: @@ -45,6 +51,7 @@ func open() -> void: # Called every frame on the beat grid. Returns true when a new passage starts. func update(grid: float, section: String, dropped: bool, faders: Dictionary, identity: String) -> bool: _hall_on = float(faders.get("hall", 1.0)) > 0.3 + _clock = grid if identity != _identity or grid < started - 8.0: _identity = identity _rng.seed = hash(identity + ":passage") @@ -81,6 +88,7 @@ func _start(at: float, section: String) -> void: length = 128.0 if quiet else [128.0, 128.0, 64.0][_rng.randi_range(0, 2)] var pool: Array = FIGURES.keys() if not _hall_on: pool.erase("overhead") # the hall is off on the desk: the stage carries every passage + if not stepped: pool.erase("rain") # rain is a techno figure: the other heads stand still meanwhile var options := [] for name in pool: for lean in [-1.0, 1.0]: @@ -102,6 +110,11 @@ func role(group: String) -> String: if figure == "overhead" and group in ["rear", "side"]: return "support" return "hold" +# How strong a group plays in this passage; in the rain the hall steps back almost to dark, so the rain reads. +func level(group: String, part: String) -> float: + if figure == "rain" and group == "hall": return 0.05 + return LEVEL[part] + func stage_leads() -> bool: return figure != "overhead" @@ -148,6 +161,7 @@ func stage_target(group: String, row: int, mount: Vector3, xn: float, u: float, var b := begin() var side := -1.0 if mount.x < 0.0 else 1.0 var drift := sin(m * 0.18 + u * 0.9) * 0.8 + if figure == "rain" and group in RAIN_GROUPS: return _rain(mount) match group: "back": if row == 3: return _back_spot(mount, u, d, m) @@ -198,6 +212,25 @@ func stage_target(group: String, row: int, mount: Vector3, xn: float, u: float, return Vector3(-side * 6.0, mount.y + 1.5, mount.z + (u - 0.5) * 4.0 + mirror * d * 3.0) return Vector3.INF +# Where a falling beam aims: from 45° up to 45° down towards the stage, each beam on its own moment, in its own lane. +func _rain(mount: Vector3) -> Vector3: + var t := _rain_t(mount) + var a := deg_to_rad(lerpf(45.0, -45.0, minf(t / RAIN_FALL, 1.0))) + var d := Vector3(0.0, sin(a), -cos(a)) + # aim at the point where the ray meets the roof or the floor, so the angle survives the hall's limits + var reach := (14.4 - mount.y) / d.y if d.y > 0.01 else (mount.y - 0.2) / -d.y if d.y < -0.01 else 30.0 + return mount + d * clampf(reach, 1.0, 30.0) + +# How lit a falling beam is: it fades in at the top, out near the bottom, and goes back up in the dark. +func rain_level(mount: Vector3) -> float: + var f := _rain_t(mount) / RAIN_FALL + if f >= 1.0: return 0.0 + return smoothstep(0.0, 0.1, f) * (1.0 - smoothstep(0.75, 1.0, f)) + +func _rain_t(mount: Vector3) -> float: + var own := fposmod(sin(mount.x * 12.9898 + mount.y * 78.233 + mount.z * 3.7) * 43758.5453, 1.0) + return fposmod(_clock / RAIN_BEATS + own, 1.0) + # Across the hall without piling up at a wall: a soft limit keeps every head's order and some of its spacing. static func _lane(x: float) -> float: return 23.0 * tanh(x / 23.0)