// SpinArt.cs // A carnival spin-art machine, painted with a script canvas (CreateCanvas). Squeeze paint onto a sheet of paper (one // button per colour), spin it, and the paint is flung outward: puddles burst into curved streaks that thin out, split, // and end in beads and splatter, different every time. Everybody in the world sees the same picture being made, also // players who arrive half-way (the canvas keeps its picture for them). // // Set up: // Paper an object with a flat top (a plane, a thin box, a disc) whose material shows the picture. // Empty: this script's own object. // PaperMaterial the number of that material on the object (0 = its first material). // SpinWithScript on: the script turns the paper. Off: the paper turns with its own animation (an animated model): // Start plays that animation once (not looping), and the spin lasts as long as the animation. // Start / Stop / Reset buttons, and up to seven colour buttons: any objects to click (they become interactable). // Colour bottles (optional, one per colour): any object in the scene (a group, a mesh, particles, a light...). It is // hidden when the world starts, shown to everybody while that colour is squeezed (its animation, // if it has one, plays once), and hidden again after BottleSeconds. The paint lands PaintDelay // seconds after the click, to match the bottle's squeeze. // Squirt sounds (optional, up to 3): objects with an Audio Source; one of them, picked at random, plays each squirt. // // Squeeze paint before spinning (it waits, wet, in puddles) or while it spins. Start spins it for SpinSeconds; Stop ends // early; Reset gives a fresh sheet. Without any colour buttons, random colours are squeezed on while it spins. using IHXR.Simulation; public class SpinArt : ScriptBase { protected override bool SyncTransformStateByDefault => false; [Tooltip("The sheet of paper (its picture is drawn on PaperMaterial). Empty: this object.")] public SceneObject Paper; [Tooltip("Which material of the paper shows the picture (0 = the first).")] public int PaperMaterial = 0; [Tooltip("Picture size in pixels (64 to 512).")] public int CanvasSize = 256; public SceneObject StartButton; public SceneObject StopButton; public SceneObject ResetButton; [Tooltip("On: the script turns the paper. Off: Start plays the paper's own animation once, and the spin lasts as long as it.")] public bool SpinWithScript = true; [Tooltip("Which way the paper turns (seen from above). It also sets which way the streaks curve.")] public bool Clockwise = true; [Tooltip("If the streaks curve the wrong way for your paper's texture, tick this.")] public bool MirrorStreaks = false; [Tooltip("How long one spin lasts, in seconds.")] public float SpinSeconds = 12f; [Tooltip("Spin speed in degrees per second.")] public float SpinSpeed = 540f; [Tooltip("Size of a squeeze of paint (1 = normal).")] public float SqueezeSize = 1f; [Tooltip("Colour buttons: click one to squeeze that colour onto the paper.")] public SceneObject RedButton; public SceneObject OrangeButton; public SceneObject YellowButton; public SceneObject GreenButton; public SceneObject BlueButton; public SceneObject IndigoButton; public SceneObject VioletButton; public Color Red = new Color(0.90f, 0.12f, 0.16f); public Color Orange = new Color(1.00f, 0.55f, 0.00f); public Color Yellow = new Color(1.00f, 0.86f, 0.00f); public Color Green = new Color(0.16f, 0.70f, 0.24f); public Color Blue = new Color(0.12f, 0.43f, 0.90f); public Color Indigo = new Color(0.30f, 0.16f, 0.70f); public Color Violet = new Color(0.90f, 0.24f, 0.67f); [Tooltip("Colour bottles: shown while that colour is squeezed (any scene object: a group, mesh, particles, light...).")] public SceneObject RedBottle; public SceneObject OrangeBottle; public SceneObject YellowBottle; public SceneObject GreenBottle; public SceneObject BlueBottle; public SceneObject IndigoBottle; public SceneObject VioletBottle; [Tooltip("How long a bottle stays shown after a click, in seconds.")] public float BottleSeconds = 1.5f; [Tooltip("Seconds from the click until the paint lands (when that colour has a bottle), to match its squeeze.")] public float PaintDelay = 0.5f; [Tooltip("Squirt sounds: objects with an Audio Source. One of them, picked at random, plays each time paint is squirted.")] public SceneObject SquirtSound1; public SceneObject SquirtSound2; public SceneObject SquirtSound3; [Tooltip("Which Audio Source of the sound objects plays (0 = the first).")] public int SquirtAudioSourceIndex = 0; [Tooltip("Spinning sound: an object with an Audio Source. It plays over and over while the paper spins and stops when it stops.")] public SceneObject SpinSound; [Tooltip("Which Audio Source of the spinning sound object plays (0 = the first).")] public int SpinAudioSourceIndex = 0; [Tooltip("The picture also glows (it is put in the material's emission map too).")] public bool Glow = false; [Tooltip("How brightly it glows (0 to 10; 1 = the paint's own colours at full brightness).")] public float GlowLevel = 1f; [Tooltip("See-through background: only the paint and the border show (PaperColor is not used), the rest of the surface is see-through (paint on glass, a decal).")] public bool TransparentBackground = false; public Color PaperColor = new Color(0.98f, 0.98f, 0.96f); public Color BorderColor = new Color(0.16f, 0.16f, 0.16f); // A puddle of wet paint on the paper (where it was squeezed), and the streaks it throws out while spinning. private class Puddle { public float Radius, Angle, Wet, Size; public Color Paint; } private class Streak { public float Radius, Angle, Paint, Width, Wobble; public Color Color; } private ScriptCanvas canvas; private readonly List puddles = new List(); private readonly List streaks = new List(); private bool spinning, spinSoundOn; private float spinStarted, spinAngle, nextTurnAt, autoSqueeze, spinDuration; private Vector3 baseRotation; public override void Start() { int size = Mathf.Clamp(CanvasSize, 64, 512); canvas = IsValid(Paper) ? CreateCanvas(Paper, PaperMaterial, size, size, PaperColor, transparentBackground: TransparentBackground) : CreateCanvas(PaperMaterial, size, size, PaperColor, transparentBackground: TransparentBackground); Transform t = IsValid(Paper) ? (Paper.gameObject != null ? Paper.gameObject.transform : null) : transform; baseRotation = t != null ? t.localEulerAngles : Vector3.zero; canvas.SetEmission(Glow ? GlowLevel : 0f); // Animated paper: it rests until Start (then its animation plays once). if (!SpinWithScript) { Animation anim = PaperAnimation(); if (anim != null) { anim.wrapMode = WrapMode.Once; anim.Stop(); } } FreshSheet(); Button(StartButton, "Start spinning", StartSpin); Button(StopButton, "Stop", StopSpin); Button(ResetButton, "New sheet", ResetSheet); Button(RedButton, "Red paint", () => ColourClicked(0)); Button(OrangeButton, "Orange paint", () => ColourClicked(1)); Button(YellowButton, "Yellow paint", () => ColourClicked(2)); Button(GreenButton, "Green paint", () => ColourClicked(3)); Button(BlueButton, "Blue paint", () => ColourClicked(4)); Button(IndigoButton, "Indigo paint", () => ColourClicked(5)); Button(VioletButton, "Violet paint", () => ColourClicked(6)); // The bottles start hidden, for everybody. if (IsValid(SpinSound)) StopAudio(SpinSound, null, SpinAudioSourceIndex, true); // quiet until the first spin bottles = new[] { RedBottle, OrangeBottle, YellowBottle, GreenBottle, BlueBottle, IndigoBottle, VioletBottle }; for (int i = 0; i < bottles.Length; i++) if (IsValid(bottles[i])) SetObjectVisible(bottles[i], false); StartCoroutine(Loop()); } private bool IsValid(SceneObject o) => o != null && !o.IsNull; private void Button(SceneObject button, string hint, System.Action click) { if (IsValid(button)) MakeInteractable(button, info => click(), true, null, true, 10f, hint); } private SceneObject[] bottles = new SceneObject[7]; private readonly float[] bottleHideAt = new float[7]; private readonly List> pendingPaint = new List>(); private Color ColourAt(int index) => index switch { 0 => Red, 1 => Orange, 2 => Yellow, 3 => Green, 4 => Blue, 5 => Indigo, _ => Violet }; // A colour button: its bottle appears (and plays its animation once), the paint lands a moment later, the bottle hides again. private void ColourClicked(int index) { float now = Time.time; SceneObject bottle = bottles[index]; if (!IsValid(bottle)) { Squeeze(ColourAt(index)); return; } if (bottleHideAt[index] <= now) SetObjectVisible(bottle, true); bottleHideAt[index] = now + Mathf.Max(0.2f, BottleSeconds); GameObject go = bottle.gameObject; Animation anim = go != null ? go.GetComponentInChildren(true) : null; if (anim != null) { anim.wrapMode = WrapMode.Once; anim.Rewind(); anim.Play(); } pendingPaint.Add(new KeyValuePair(now + Mathf.Max(0f, PaintDelay), index)); } private void UpdateBottles(float now) { for (int i = pendingPaint.Count - 1; i >= 0; i--) if (pendingPaint[i].Key <= now) { int index = pendingPaint[i].Value; pendingPaint.RemoveAt(i); Squeeze(ColourAt(index)); } for (int i = 0; i < bottles.Length; i++) if (bottleHideAt[i] > 0f && bottleHideAt[i] <= now) { bottleHideAt[i] = 0f; if (IsValid(bottles[i])) SetObjectVisible(bottles[i], false); } } // One of the squirt sounds, at random, from its start (also when it is still playing from the last squirt). // The spinning sound follows the spin: on while it turns, off as soon as it stops (finished, Stop or Reset). private void UpdateSpinSound() { if (spinning == spinSoundOn || !IsValid(SpinSound)) return; spinSoundOn = spinning; if (spinning) PlayAudioLoop(SpinSound, null, SpinAudioSourceIndex, true); else StopAudio(SpinSound, null, SpinAudioSourceIndex, true); } private void PlaySquirtSound() { List sounds = new List(); foreach (SceneObject sound in new[] { SquirtSound1, SquirtSound2, SquirtSound3 }) if (IsValid(sound)) sounds.Add(sound); if (sounds.Count > 0) PlayAudioFrom(sounds[Random.Range(0, sounds.Count)], 0f, null, SquirtAudioSourceIndex, true); } private bool HasColourButtons => IsValid(RedButton) || IsValid(OrangeButton) || IsValid(YellowButton) || IsValid(GreenButton) || IsValid(BlueButton) || IsValid(IndigoButton) || IsValid(VioletButton); private void FreshSheet() { canvas.Clear().Border(Mathf.Max(2, canvas.Width / 80), BorderColor); } private void StartSpin() { if (spinning) return; spinning = true; spinStarted = Time.time; spinDuration = SpinSeconds; if (!SpinWithScript) { // The paper's own animation, played once from the start; the spin lasts as long as it does (its length is known once a // player has loaded the scene; otherwise SpinSeconds). Animation anim = PaperAnimation(); if (anim != null) { foreach (AnimationState state in anim) if (state.length > 1.05f) { spinDuration = state.length / Mathf.Max(0.05f, Mathf.Abs(state.speed == 0f ? 1f : state.speed)); break; } anim.wrapMode = WrapMode.Once; anim.Rewind(); anim.Play(); } } UpdateSpinSound(); } private void StopSpin() { if (spinning && !SpinWithScript) { Animation anim = PaperAnimation(); if (anim != null) anim.Stop(); } spinning = false; UpdateSpinSound(); } private Animation PaperAnimation() { GameObject go = IsValid(Paper) ? Paper.gameObject : gameObject; return go != null ? go.GetComponentInChildren(true) : null; } private void ResetSheet() { spinning = false; UpdateSpinSound(); puddles.Clear(); streaks.Clear(); FreshSheet(); } // ---------------------------------------------------------------- paint // A squeeze of the bottle: one to three wet puddles close together, each with an uneven edge. private void Squeeze(Color paint) { PlaySquirtSound(); float half = canvas.Width / 2f, scale = Mathf.Clamp(SqueezeSize, 0.3f, 3f) * canvas.Width / 256f; float radius = Random.Range(0f, 0.6f) * half, angle = Random.Range(0f, Mathf.PI * 2f); int count = Random.Range(1, 4); puddles.RemoveAll(p => p.Wet <= 0.02f); // dried up: they never stop a new squeeze for (int i = 0; i < count && puddles.Count < 120; i++) { Puddle p = new Puddle { Radius = Mathf.Max(0f, radius + Random.Range(-6f, 6f) * scale), Angle = angle + Random.Range(-0.15f, 0.15f), Wet = Random.Range(0.7f, 1.3f), Size = Random.Range(5f, 11f) * scale, Paint = paint }; puddles.Add(p); for (int k = 0; k < 7; k++) { float r = p.Radius + Random.Range(-0.4f, 0.4f) * p.Size, a = p.Angle + Random.Range(-1f, 1f) * p.Size * 0.4f / Mathf.Max(6f, p.Radius); canvas.Dot(X(r, a), Y(r, a), Mathf.Max(2, Mathf.RoundToInt(p.Size * Random.Range(0.8f, 1.6f))), Tint(p.Paint, Random.Range(0.9f, 1.08f))); } } } private System.Collections.IEnumerator Loop() { const float step = 0.05f; // 20 paint steps a second; the canvas sends them 4 times a second while (true) { yield return new WaitForSeconds(step); float now = Time.time, spin = 0f; if (spinning) { float elapsed = now - spinStarted; if (elapsed >= spinDuration) { spinning = false; UpdateSpinSound(); } // Spins up over a second and a half, slows down over the last two seconds. else spin = Mathf.Clamp01(elapsed / 1.5f) * Mathf.Clamp01((spinDuration - elapsed) / 2f); } if (spin > 0f && !HasColourButtons) { autoSqueeze += step; if (autoSqueeze > 0.6f && Random.value < 0.15f) { autoSqueeze = 0f; Squeeze(RandomColour()); } } float degrees = SpinSpeed * spin * (Clockwise ? -1f : 1f); spinAngle = Mathf.Repeat(spinAngle + degrees * step, 360f); if (SpinWithScript && spin > 0f && now >= nextTurnAt) { // Turned smoothly, always the same way: it is sent the angle it reaches a moment later, in steps of at most // 45 degrees (a turn is eased the shortest way round, so bigger steps, delayed a little on the network, could // look like turning back). float ahead = Mathf.Clamp(45f / Mathf.Max(1f, Mathf.Abs(degrees)), step, 0.25f); nextTurnAt = now + ahead; Vector3 turned = new Vector3(baseRotation.x, baseRotation.y + spinAngle + degrees * ahead, baseRotation.z); if (IsValid(Paper)) LerpLocalRotation(Paper, turned, ahead); else LerpLocalRotation(turned, ahead); } UpdateBottles(now); Fling(step, spin); } } // While it spins, wet puddles throw out streaks; streaks run outward (faster further out and while there is more paint // in them), curve back against the spin, wobble, split, thin out, and end in a bead with a few flung droplets. private void Fling(float dt, float spin) { float half = canvas.Width / 2f, curl = (Clockwise ? 1f : -1f) * (MirrorStreaks ? -1f : 1f), scale = canvas.Width / 256f; if (spin > 0.15f) { foreach (Puddle p in puddles) { if (p.Wet <= 0.02f || Random.value > spin * 0.9f) continue; int count = Mathf.Max(1, Mathf.RoundToInt(Random.Range(1f, 3f) * spin)); for (int k = 0; k < count && p.Wet > 0.02f && streaks.Count < 260; k++) { float paint = Mathf.Min(p.Wet, Random.Range(0.05f, 0.18f)); p.Wet -= paint; streaks.Add(new Streak { Radius = p.Radius + Random.Range(0f, p.Size * 0.5f), Angle = p.Angle + Random.Range(-1f, 1f) * p.Size * 1.3f / Mathf.Max(6f, p.Radius), Paint = paint * Random.Range(0.6f, 1.5f), Color = Tint(p.Paint, Random.Range(0.85f, 1.1f)), Width = p.Size * Random.Range(0.2f, 0.5f), Wobble = Random.Range(-1f, 1f) }); } } } for (int i = streaks.Count - 1; i >= 0; i--) { Streak s = streaks[i]; float r0 = s.Radius, a0 = s.Angle; float speed = (18f + s.Radius * 2.4f / scale) * scale * Mathf.Max(spin, 0.05f) * (0.5f + s.Paint * 3f); s.Radius += speed * dt; s.Angle += (curl * -2.2f * spin / Mathf.Max(10f * scale, s.Radius) + s.Wobble * 0.006f / scale) * speed * dt * 0.15f; s.Wobble = Mathf.Clamp(s.Wobble + Random.Range(-0.3f, 0.3f), -1.5f, 1.5f); s.Paint -= dt * speed * 0.0016f / scale; int width = Mathf.Clamp(Mathf.RoundToInt(s.Width * Mathf.Sqrt(Mathf.Max(0f, s.Paint) / 0.12f)), 1, Mathf.RoundToInt(8 * scale)); canvas.Line(X(r0, a0), Y(r0, a0), X(s.Radius, s.Angle), Y(s.Radius, s.Angle), s.Color, width); if (Random.value < 0.012f) { float r = s.Radius + Random.Range(2f, 8f) * scale, a = s.Angle + Random.Range(-0.06f, 0.06f); canvas.Dot(X(r, a), Y(r, a), Random.Range(1, 4), s.Color); } if (Random.value < 0.04f && s.Paint > 0.04f && streaks.Count < 260) { s.Paint *= 0.6f; streaks.Add(new Streak { Radius = s.Radius, Angle = s.Angle + Random.Range(-0.08f, 0.08f), Paint = s.Paint * 0.7f, Color = s.Color, Width = s.Width * 0.6f, Wobble = Random.Range(-1f, 1f) }); } bool offEdge = s.Radius > half * 1.1f; if (s.Paint <= 0.004f || offEdge || spin <= 0f) { if (!offEdge) { canvas.Dot(X(s.Radius, s.Angle), Y(s.Radius, s.Angle), Mathf.Max(2, width + 1), s.Color); for (int k = 0; k < 3; k++) if (Random.value < 0.6f) { float r = s.Radius + Random.Range(3f, 18f) * scale, a = s.Angle + Random.Range(-0.05f, 0.05f); canvas.Dot(X(r, a), Y(r, a), Random.Range(1, 5), s.Color); } } streaks.RemoveAt(i); } } } private Color RandomColour() { Color[] all = { Red, Orange, Yellow, Green, Blue, Indigo, Violet }; return all[Random.Range(0, all.Length)]; } private static Color Tint(Color c, float f) => new Color(Mathf.Clamp01(c.r * f), Mathf.Clamp01(c.g * f), Mathf.Clamp01(c.b * f), 1f); private int X(float radius, float radians) => Mathf.RoundToInt(canvas.Width / 2f + Mathf.Cos(radians) * radius); private int Y(float radius, float radians) => Mathf.RoundToInt(canvas.Height / 2f + Mathf.Sin(radians) * radius); }