|
| 1 | +import * as cg from "../render/core/cg.js"; |
| 2 | +import { loadSound, playSoundAtPosition } from "../util/positional-audio.js"; |
| 3 | + |
| 4 | +const TARGET_COUNT = 36; |
| 5 | +const TRAIL_COUNT = 24; |
| 6 | +const STRIKE_Z = -0.55; |
| 7 | +const DESPAWN_Z = -0.15; |
| 8 | +const SPAWN_Z = -5.6; |
| 9 | + |
| 10 | +const lanes = [-1.0, -0.35, 0.35, 1.0]; |
| 11 | +const heights = [1.15, 1.5, 1.85]; |
| 12 | + |
| 13 | +const leftColor = [0.22, 0.9, 1.0]; |
| 14 | +const rightColor = [1.0, 0.35, 0.25]; |
| 15 | +const neutralColor = [0.95, 0.9, 0.35]; |
| 16 | +const pulseColor = [0.2, 0.9, 0.5]; |
| 17 | + |
| 18 | +let soundBuffer = [], loadSounds = []; |
| 19 | +for (let i = 0; i < 6; i++) |
| 20 | + loadSounds.push(loadSound("../../media/sound/bounce/" + i + ".wav", buffer => soundBuffer[i] = buffer)); |
| 21 | +Promise.all(loadSounds); |
| 22 | + |
| 23 | +const makeTarget = () => ({ |
| 24 | + active: false, |
| 25 | + hand: "any", |
| 26 | + lane: 0, |
| 27 | + level: 1, |
| 28 | + spawnTime: 0, |
| 29 | + life: 0, |
| 30 | + speed: 0, |
| 31 | + hitFlash: 0, |
| 32 | + pos: [0, 1.5, SPAWN_Z], |
| 33 | +}); |
| 34 | + |
| 35 | +const targetColor = hand => hand == "left" ? leftColor : hand == "right" ? rightColor : neutralColor; |
| 36 | + |
| 37 | +const choosePattern = beat => { |
| 38 | + const phase = beat % 16; |
| 39 | + |
| 40 | + if (phase == 12 || phase == 13 || phase == 14) |
| 41 | + return { step: 0.23, count: 1, speed: 3.4, mode: "burst" }; |
| 42 | + |
| 43 | + if (phase >= 8) |
| 44 | + return { step: 0.36, count: 1, speed: 3.0, mode: "cross" }; |
| 45 | + |
| 46 | + return { step: 0.42, count: 1, speed: 2.7, mode: "flow" }; |
| 47 | +}; |
| 48 | + |
| 49 | +export const init = async model => { |
| 50 | + let beat = 0; |
| 51 | + let nextSpawnTime = 0; |
| 52 | + let combo = 0; |
| 53 | + let bestCombo = 0; |
| 54 | + let score = 0; |
| 55 | + let hits = 0; |
| 56 | + let misses = 0; |
| 57 | + let coachPulse = 0; |
| 58 | + |
| 59 | + let targets = []; |
| 60 | + let trails = []; |
| 61 | + |
| 62 | + for (let i = 0; i < TARGET_COUNT; i++) { |
| 63 | + targets.push(makeTarget()); |
| 64 | + model.add("sphere"); |
| 65 | + } |
| 66 | + |
| 67 | + for (let i = 0; i < TRAIL_COUNT; i++) { |
| 68 | + trails.push({ pos: [0, 1.5, -1], life: 0, color: [1, 1, 1] }); |
| 69 | + model.add("sphere"); |
| 70 | + } |
| 71 | + |
| 72 | + const leftGuide = model.add("ringZ"); |
| 73 | + const rightGuide = model.add("ringZ"); |
| 74 | + const pulseRing = model.add("ringY"); |
| 75 | + |
| 76 | + const horizon = model.add("square"); |
| 77 | + const floor = model.add("square"); |
| 78 | + const leftPeak = model.add("coneY"); |
| 79 | + const rightPeak = model.add("coneY"); |
| 80 | + const moon = model.add("sphere"); |
| 81 | + |
| 82 | + let addTrail = (pos, color) => { |
| 83 | + trails.unshift({ pos: [...pos], life: 1, color: [...color] }); |
| 84 | + trails.pop(); |
| 85 | + }; |
| 86 | + |
| 87 | + let spawnTarget = (spawnAt, patternMode, speed) => { |
| 88 | + for (let i = 0; i < TARGET_COUNT; i++) { |
| 89 | + if (targets[i].active) |
| 90 | + continue; |
| 91 | + |
| 92 | + const lane = patternMode == "cross" |
| 93 | + ? (beat % 2 == 0 ? 0 : 3) |
| 94 | + : 4 * Math.random() >> 0; |
| 95 | + |
| 96 | + const level = patternMode == "burst" |
| 97 | + ? ((beat + i) % 3) |
| 98 | + : (3 * Math.random() >> 0); |
| 99 | + |
| 100 | + let hand = "any"; |
| 101 | + if (lane < 2) |
| 102 | + hand = "left"; |
| 103 | + if (lane > 1) |
| 104 | + hand = "right"; |
| 105 | + if (patternMode == "flow" && beat % 4 == 3) |
| 106 | + hand = "any"; |
| 107 | + |
| 108 | + targets[i].active = true; |
| 109 | + targets[i].hand = hand; |
| 110 | + targets[i].lane = lane; |
| 111 | + targets[i].level = level; |
| 112 | + targets[i].spawnTime = spawnAt; |
| 113 | + targets[i].life = 1; |
| 114 | + targets[i].speed = speed; |
| 115 | + targets[i].hitFlash = 0; |
| 116 | + targets[i].pos = [lanes[lane], heights[level], SPAWN_Z]; |
| 117 | + return; |
| 118 | + } |
| 119 | + }; |
| 120 | + |
| 121 | + let playHit = index => { |
| 122 | + if (soundBuffer.length == 0) |
| 123 | + return; |
| 124 | + playSoundAtPosition(soundBuffer[index % soundBuffer.length], targets[index].pos); |
| 125 | + }; |
| 126 | + |
| 127 | + model.animate(() => { |
| 128 | + const t = model.time; |
| 129 | + const dt = model.deltaTime; |
| 130 | + const bpm = 132 + 16 * Math.sin(0.05 * t); |
| 131 | + const beatDuration = 60 / bpm; |
| 132 | + |
| 133 | + while (t >= nextSpawnTime) { |
| 134 | + const pattern = choosePattern(beat); |
| 135 | + for (let i = 0; i < pattern.count; i++) |
| 136 | + spawnTarget(nextSpawnTime + i * pattern.step, pattern.mode, pattern.speed); |
| 137 | + |
| 138 | + nextSpawnTime += beatDuration; |
| 139 | + beat++; |
| 140 | + coachPulse = 1; |
| 141 | + } |
| 142 | + |
| 143 | + const leftHand = clientState.finger(clientID, "left", 1); |
| 144 | + const rightHand = clientState.finger(clientID, "right", 1); |
| 145 | + |
| 146 | + if (Array.isArray(leftHand)) |
| 147 | + addTrail(leftHand, leftColor); |
| 148 | + if (Array.isArray(rightHand)) |
| 149 | + addTrail(rightHand, rightColor); |
| 150 | + |
| 151 | + coachPulse *= 0.93; |
| 152 | + |
| 153 | + for (let i = 0; i < TARGET_COUNT; i++) { |
| 154 | + const targetNode = model.child(i); |
| 155 | + const target = targets[i]; |
| 156 | + |
| 157 | + if (!target.active) { |
| 158 | + targetNode.identity().scale(0); |
| 159 | + continue; |
| 160 | + } |
| 161 | + |
| 162 | + target.pos[2] += target.speed * dt; |
| 163 | + target.hitFlash *= 0.86; |
| 164 | + |
| 165 | + let gotHit = false; |
| 166 | + const hitRadius = 0.28; |
| 167 | + |
| 168 | + if (Array.isArray(leftHand) && target.hand != "right" && cg.distance(leftHand, target.pos) < hitRadius) |
| 169 | + gotHit = true; |
| 170 | + |
| 171 | + if (Array.isArray(rightHand) && target.hand != "left" && cg.distance(rightHand, target.pos) < hitRadius) |
| 172 | + gotHit = true; |
| 173 | + |
| 174 | + if (gotHit) { |
| 175 | + target.hitFlash = 1; |
| 176 | + target.active = false; |
| 177 | + combo++; |
| 178 | + hits++; |
| 179 | + score += 10 + combo; |
| 180 | + bestCombo = Math.max(bestCombo, combo); |
| 181 | + coachPulse = 1; |
| 182 | + playHit(i); |
| 183 | + continue; |
| 184 | + } |
| 185 | + |
| 186 | + if (target.pos[2] > DESPAWN_Z) { |
| 187 | + target.active = false; |
| 188 | + misses++; |
| 189 | + combo = 0; |
| 190 | + continue; |
| 191 | + } |
| 192 | + |
| 193 | + const c = targetColor(target.hand); |
| 194 | + const glow = 0.2 + 0.25 * Math.sin(14 * t + i); |
| 195 | + const depthScale = 1.05 + 0.8 * (target.pos[2] - STRIKE_Z) / (SPAWN_Z - STRIKE_Z); |
| 196 | + |
| 197 | + targetNode.identity() |
| 198 | + .move(target.pos) |
| 199 | + .scale(0.13 * depthScale) |
| 200 | + .color(c[0] + glow, c[1] + glow, c[2] + glow); |
| 201 | + } |
| 202 | + |
| 203 | + for (let i = 0; i < TRAIL_COUNT; i++) { |
| 204 | + const n = model.child(TARGET_COUNT + i); |
| 205 | + const tr = trails[i]; |
| 206 | + tr.life *= 0.9; |
| 207 | + |
| 208 | + if (tr.life < 0.06) { |
| 209 | + n.identity().scale(0); |
| 210 | + continue; |
| 211 | + } |
| 212 | + |
| 213 | + n.identity().move(tr.pos).scale(0.04 * tr.life) |
| 214 | + .color(tr.color[0] * tr.life, tr.color[1] * tr.life, tr.color[2] * tr.life); |
| 215 | + } |
| 216 | + |
| 217 | + const leftGuidePos = Array.isArray(leftHand) ? leftHand : [-0.45, 1.4, -0.55]; |
| 218 | + const rightGuidePos = Array.isArray(rightHand) ? rightHand : [0.45, 1.4, -0.55]; |
| 219 | + |
| 220 | + leftGuide.identity().move(leftGuidePos).scale(0.1 + 0.03 * Math.sin(9 * t)).color(...leftColor); |
| 221 | + rightGuide.identity().move(rightGuidePos).scale(0.1 + 0.03 * Math.sin(9 * t + 1)).color(...rightColor); |
| 222 | + |
| 223 | + pulseRing.identity().move(0, 1.5, STRIKE_Z - 0.02) |
| 224 | + .turnY(2 * t) |
| 225 | + .scale(1.0 + 0.45 * coachPulse) |
| 226 | + .color(pulseColor[0], pulseColor[1], pulseColor[2]); |
| 227 | + |
| 228 | + horizon.identity().move(0, 2.0, -7.5).scale(9.5, 4.8, 1).color(0.06, 0.1, 0.2); |
| 229 | + floor.identity().move(0, 0.72, -2.8).turnX(-Math.PI / 2).scale(3.2, 3.2, 1).color(0.04, 0.06, 0.12); |
| 230 | + leftPeak.identity().move(-4.8, 0.8, -8.0).scale(1.8, 3.2, 1.8).color(0.08, 0.14, 0.2); |
| 231 | + rightPeak.identity().move(4.8, 0.82, -8.1).scale(2.1, 3.4, 2.1).color(0.09, 0.12, 0.22); |
| 232 | + moon.identity().move(2.4, 3.1, -7.0).scale(0.32).color(0.85, 0.95, 1.0); |
| 233 | + |
| 234 | + const total = hits + misses; |
| 235 | + const accuracy = total > 0 ? Math.floor(100 * hits / total) : 100; |
| 236 | + |
| 237 | + while (model.nChildren() > TARGET_COUNT + TRAIL_COUNT + 8) |
| 238 | + model.remove(TARGET_COUNT + TRAIL_COUNT + 8); |
| 239 | + |
| 240 | + model.add(clay.text("RHYTHM CARDIO")).move(-1.05, 2.52, -1.85).scale(1.95).color(0.9, 1.0, 1.0); |
| 241 | + model.add(clay.text("SCORE " + score)).move(-1.05, 2.30, -1.85).scale(1.2).color(0.85, 0.95, 1.0); |
| 242 | + model.add(clay.text("COMBO " + combo + " BEST " + bestCombo)).move(-1.05, 2.13, -1.85).scale(1.08).color(1.0, 0.95, 0.5); |
| 243 | + model.add(clay.text("ACCURACY " + accuracy + "%")) |
| 244 | + .move(-1.05, 1.96, -1.85).scale(1.08).color(0.5, 1.0, 0.8); |
| 245 | + model.add(clay.text("STRIKE IN TIME WITH THE BEAT")) |
| 246 | + .move(-1.05, 1.73, -1.85).scale(0.92).color(0.65, 0.85, 1.0); |
| 247 | + }); |
| 248 | +}; |
0 commit comments