<!doctype html>
<title>L'ura Phase 4 β 3D</title> <style> :root{color-scheme:dark; --bg:#060812;--panel:rgb(12 14 24/88%);--text:#f5f7ff; --muted:#aeb5ca;--blue:#85d8ff;--gold:#ffd45a;--danger:#ff5f86;} *{box-sizing:border-box;} body{margin:0;overflow:hidden;background:var(--bg);color:var(--text); font-family:Inter,ui-sans-serif,system-ui,sans-serif;} #scene{position:fixed;inset:0;} .hud{position:fixed;left:16px;top:16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;z-index:2;pointer-events:none;} .pill,.controls,.legend{background:var(--panel);border:1px solid rgb(255 255 255/13%);border-radius:8px;box-shadow:0 18px 50px rgb(0 0 0/30%);} .pill{padding:8px 10px;color:var(--muted);font-size:13px;} .pill strong{color:var(--text);} .controls{position:fixed;right:16px;top:16px;width:252px;padding:14px;z-index:3;} .controls h1{margin:0 0 4px;font-size:18px;} .controls p{margin:0 0 10px;color:var(--muted);font-size:12px;line-height:1.35;} .row{display:flex;gap:8px;align-items:center;margin-top:10px;} button{border:1px solid rgb(255 255 255/14%);border-radius:8px;background:#1d2134; color:var(--text);padding:8px 10px;font:inherit;cursor:pointer;} button:hover{border-color:rgb(255 255 255/30%);} input[type="range"]{width:100%;accent-color:var(--blue);} .helper-row{margin-top:10px;display:flex;align-items:flex-start;gap:8px;} .helper-row input[type="checkbox"]{margin-top:2px;accent-color:var(--gold);width:15px;height:15px;flex-shrink:0;cursor:pointer;} .helper-row label{font-size:11px;color:var(--muted);cursor:pointer;line-height:1.4;} .helper-row label strong{color:#ffcc44;} .legend{position:fixed;left:16px;bottom:16px;width:min(540px,calc(100vw - 32px)); padding:12px 14px;color:var(--muted);font-size:12px;line-height:1.5;z-index:2;} .legend strong{color:var(--text);}#raidcall{position:fixed;top:68px;left:50%;transform:translateX(-50%);
display:flex;gap:6px;align-items:stretch;
background:rgb(4 6 16/86%);border:1px solid rgb(255 255 255/12%);
border-radius:10px;padding:8px 12px;z-index:4;pointer-events:none;
opacity:0;transition:opacity .25s;}
#raidcall.show{opacity:1;}
.sp-slot{display:flex;flex-direction:column;align-items:center;gap:2px;
padding:6px 14px;border-radius:6px;border:1px solid transparent;min-width:78px;transition:all .15s;}
.sp-slot .dir{font-size:17px;font-weight:900;color:var(--muted);white-space:nowrap;}
.sp-slot .lbl{font-size:11px;color:var(--muted);}
.sp-slot.stepping .dir,.sp-slot.stepping .lbl{color:var(--gold);}
.sp-slot.stepping{border-color:var(--gold);background:rgb(255 212 90/12%);}
#movecall{position:fixed;top:44%;left:50%;transform:translate(-50%,-50%);
text-align:center;z-index:4;pointer-events:none;opacity:0;transition:opacity .18s;}
#movecall.show{opacity:1;}
#movecall .movelabel{font-size:20px;font-weight:800;color:var(--gold);
text-shadow:0 2px 14px rgba(0,0,0,.95);letter-spacing:.12em;}
#movecall .bignum{font-size:104px;font-weight:900;line-height:.95;color:#fff;
text-shadow:0 0 48px rgba(255,212,90,.8),0 4px 24px rgba(0,0,0,.95);}
#message{position:fixed;top:20%;left:50%;transform:translateX(-50%);
font-size:30px;font-weight:900;text-align:center;z-index:5;pointer-events:none;
text-shadow:0 2px 20px rgba(0,0,0,.95);opacity:0;transition:opacity .25s;
letter-spacing:.04em;white-space:nowrap;}
#message.show{opacity:1;}
#message.danger{color:var(--danger);}
#message.bad{color:#ff9f1c;}
#message.alert{color:#fff;text-shadow:0 0 32px rgba(255,212,90,.9),0 2px 20px rgba(0,0,0,.95);}
/* Mode select overlay */
#mode-select{position:fixed;inset:0;z-index:200;
background:rgb(6 8 18/97%);backdrop-filter:blur(4px);
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;}
#mode-select h2{font-size:26px;font-weight:900;color:#fff;margin:0;text-align:center;letter-spacing:.06em;}
#mode-select .mode-sub{color:var(--muted);font-size:13px;margin:0;text-align:center;}
.mode-btn{width:280px;padding:14px 22px;border-radius:10px;font-size:14px;font-weight:700;
border:2px solid;cursor:pointer;text-align:left;transition:all .15s;background:transparent;font-family:inherit;}
.mode-btn .mode-title{display:block;font-size:15px;}
.mode-btn .mode-desc{display:block;font-size:11px;font-weight:400;margin-top:3px;opacity:.75;}
.mode-easy{border-color:#a8e563;color:#a8e563;}
.mode-easy:hover{background:rgb(168 229 99/14%);}
.mode-normal{border-color:#85d8ff;color:#85d8ff;}
.mode-normal:hover{background:rgb(133 216 255/14%);}
.mode-chaotic{border-color:#ff9f1c;color:#ff9f1c;}
.mode-chaotic:hover{background:rgb(255 159 28/14%);}
/* Role selector */
#role-step,#diff-step{display:flex;flex-direction:column;align-items:center;gap:14px;}
.role-btn{width:280px;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:700;
border:2px solid;cursor:pointer;text-align:left;transition:all .15s;background:transparent;font-family:inherit;}
.role-btn .role-title{display:block;font-size:14px;}
.role-btn .role-desc{display:block;font-size:11px;font-weight:400;margin-top:3px;opacity:.75;}
.role-dps{border-color:#85d8ff;color:#85d8ff;}.role-dps:hover{background:rgb(133 216 255/14%);}
.role-light{border-color:#ffe066;color:#ffe066;}.role-light:hover{background:rgb(255 224 102/14%);}
.role-tank{border-color:#ff7a45;color:#ff7a45;}.role-tank:hover{background:rgb(255 122 69/14%);}
.step-back{background:none;border:none;color:var(--muted);font-size:12px;cursor:pointer;
font-family:inherit;padding:4px 8px;text-decoration:underline;margin-top:-4px;}
.step-back:hover{color:#fff;}
.eab-row{display:flex;align-items:center;gap:8px;}
.eab-row label{font-size:12px;color:var(--muted);}
.eab-key-btn{width:38px;height:38px;border-radius:6px;border:2px solid #ff7a45;
background:rgb(255 122 69/12%);color:#ff7a45;font-size:13px;font-weight:700;
cursor:pointer;font-family:inherit;}
.eab-key-btn.capturing{border-color:#fff;color:#fff;animation:blink .5s infinite;}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.35}}
/* EAB cooldown icon */
#eab-icon{position:fixed;bottom:88px;left:50%;transform:translateX(-50%);
z-index:5;display:none;flex-direction:column;align-items:center;gap:4px;}
#eab-icon.show{display:flex;}
#eab-icon canvas{border-radius:8px;border:2px solid #ff7a45;display:block;}
#eab-label{font-size:10px;color:#ff7a45;font-weight:700;letter-spacing:.08em;}
/* Defensive charges */
#def-charges{position:fixed;bottom:88px;left:calc(50% + 64px);
z-index:5;display:none;flex-direction:column;align-items:center;gap:4px;}
#def-charges.show{display:flex;}
#def-pips{display:flex;gap:4px;}
.def-pip{width:14px;height:14px;border-radius:3px;border:2px solid #66aaff;background:rgb(102 170 255/35%);}
.def-pip.spent{border-color:#333;background:transparent;}
#def-key-label{font-size:10px;color:#66aaff;font-weight:700;letter-spacing:.08em;}
/* Defensive bar */
#def-bar-wrap{position:fixed;top:25%;left:50%;transform:translateX(-50%);
width:320px;z-index:5;display:none;flex-direction:column;align-items:center;gap:4px;}
#def-bar-wrap.show{display:flex;}
.def-bar-label{font-size:12px;font-weight:700;color:#66aaff;letter-spacing:.1em;text-shadow:0 0 12px rgba(100,180,255,.6);}
.def-track{width:100%;height:14px;background:rgb(0 0 0/55%);border:1px solid rgb(100 170 255/35%);border-radius:4px;overflow:hidden;}
#def-fill{height:100%;width:100%;background:linear-gradient(90deg,#3399ff,#88ccff);border-radius:4px;transition:width .1s linear;}
/* Penalty flash */
#penalty-flash{position:fixed;inset:0;z-index:3;pointer-events:none;
background:rgba(220,0,0,0.4);opacity:0;transition:opacity .22s ease-out;}
#penalty-flash.on{opacity:1;transition:none;}
/* Starsplinter flash β blue edge vignette */
#sp-flash{position:fixed;inset:0;z-index:3;pointer-events:none;
box-shadow:inset 0 0 110px 30px rgba(60,170,255,0.7);opacity:0;transition:opacity .55s ease-out;}
#sp-flash.on{opacity:1;transition:none;}
/* Reintegration hint */
#reint-hint{position:fixed;top:52%;left:50%;transform:translate(-50%,-50%);
font-size:15px;font-weight:600;color:#85d8ff;text-align:center;z-index:4;pointer-events:none;
background:rgb(4 8 24/85%);border:1px solid rgb(133 216 255/28%);border-radius:8px;
padding:11px 22px;opacity:0;transition:opacity .4s;line-height:1.6;max-width:420px;}
#reint-hint.show{opacity:1;}
/* Stun-phase role hints */
#stun-hints{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
z-index:12;pointer-events:none;display:flex;flex-direction:column;
align-items:center;gap:14px;width:min(680px,90vw);}
.hint-line{font-size:19px;font-weight:700;color:#fff;text-align:center;
text-shadow:0 2px 18px rgba(0,0,0,.95),0 0 40px rgba(100,180,255,.2);
background:rgb(4 8 24/82%);border:1px solid rgb(133 216 255/22%);
border-radius:9px;padding:10px 22px;opacity:0;line-height:1.35;
width:100%;box-sizing:border-box;}
/* Chaotic toggle */
.chaotic-row{margin-top:10px;padding-top:10px;border-top:1px solid rgb(255 255 255/10%);display:flex;align-items:flex-start;gap:8px;}
.chaotic-row input[type="checkbox"]{margin-top:2px;accent-color:#ff9f1c;width:15px;height:15px;flex-shrink:0;cursor:pointer;}
.chaotic-row label{font-size:11px;cursor:pointer;line-height:1.4;color:#ff9f1c;}
.chaotic-row label span{color:var(--muted);}
/* Boss cast bar */
#boss-castbar{position:fixed;top:10px;left:50%;transform:translateX(-50%);
width:230px;z-index:4;pointer-events:none;opacity:0;transition:opacity .18s;
background:rgb(0 0 0/78%);border:1px solid rgb(255 255 255/22%);border-radius:5px;
padding:5px 8px;}
#boss-castbar.show{opacity:1;}
.bcb-label{font-size:11px;font-weight:700;color:#fff;margin-bottom:4px;text-align:center;
letter-spacing:.06em;text-transform:uppercase;}
.bcb-track{height:11px;background:rgb(0 0 0/55%);border:1px solid rgb(255 255 255/14%);border-radius:3px;overflow:hidden;}
.bcb-fill{height:100%;width:0%;border-radius:3px;transition:background .2s;}
.bcb-fill.casting{background:linear-gradient(90deg,#7c3aed,#c084fc);}
.bcb-fill.channeling{background:linear-gradient(90deg,#ff2a43,#ff8c00);}
/* World Cup popup */
#worldcup{position:fixed;z-index:20;pointer-events:none;opacity:0;transition:opacity .15s;
font-size:18px;font-weight:900;color:#fff;text-shadow:0 0 18px rgba(255,165,0,.9),0 2px 12px rgba(0,0,0,.9);
background:rgb(10 20 5/88%);border:2px solid #ffcc00;border-radius:8px;padding:8px 14px;
max-width:220px;line-height:1.3;}
#worldcup.show{opacity:1;}
/* Guild chat */
#guildchat{position:fixed;left:16px;bottom:130px;width:270px;z-index:6;pointer-events:none;
background:rgb(4 6 14/82%);border:1px solid rgb(255 255 255/14%);border-radius:6px;
opacity:0;transition:opacity .3s;max-height:200px;display:flex;flex-direction:column;overflow:hidden;}
#guildchat.show{opacity:1;}
.gc-header{font-size:10px;font-weight:700;padding:4px 8px;color:#aaa;border-bottom:1px solid rgb(255 255 255/10%);letter-spacing:.1em;}
#gc-messages{padding:4px 8px 6px;font-size:11px;line-height:1.65;overflow:hidden;}
.gc-msg{animation:gcfade .25s ease-in;}
@keyframes gcfade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.gc-name{color:#ffd45a;font-weight:700;}
.gc-leave{color:#8888aa;}
.gc-resp{color:#4dcc4d;}
/* Discord notification */
#discord-notif{position:fixed;right:16px;bottom:80px;width:270px;z-index:6;pointer-events:none;
background:rgb(28 29 33/96%);border:1px solid rgb(255 255 255/12%);border-radius:8px;
padding:10px 12px;display:flex;gap:10px;align-items:flex-start;
opacity:0;transition:opacity .2s;box-shadow:0 6px 24px rgb(0 0 0/50%);}
#discord-notif.show{opacity:1;}
.dc-app{font-size:10px;color:#72767d;margin-bottom:2px;letter-spacing:.06em;}
.dc-sender{font-size:12px;font-weight:700;color:#00aff4;margin-bottom:2px;}
.dc-text{font-size:12px;color:#dcddde;line-height:1.4;}
.dc-icon{font-size:22px;flex-shrink:0;margin-top:2px;}
/* LUA error dialog */
#lua-error{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
z-index:60;width:480px;max-width:calc(100vw - 32px);
background:#1a1208;border:2px solid #c8a020;border-radius:3px;
box-shadow:0 0 60px rgba(0,0,0,.98),0 0 0 1px #4a3800;
font-family:'Courier New',monospace;display:none;}
#lua-error.show{display:block;}
.lua-title{background:#3d2800;padding:6px 12px;font-size:13px;font-weight:700;
color:#ffd45a;border-bottom:1px solid #6a4800;letter-spacing:.06em;}
.lua-body{padding:12px;max-height:190px;overflow-y:auto;
font-size:11px;color:#dd9900;line-height:1.65;word-break:break-all;white-space:pre-wrap;}
.lua-footer{padding:8px 12px;border-top:1px solid #3a2800;display:flex;justify-content:flex-end;}
.lua-close-btn{background:#3d2800;border:1px solid #c8a020;color:#ffd45a;
padding:5px 20px;font:inherit;font-size:12px;cursor:pointer;border-radius:2px;}
.lua-close-btn:hover{background:#5a3c00;}
/* Death reaction */
#death-msg{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
font-size:38px;font-weight:900;text-align:center;z-index:5;pointer-events:none;
text-shadow:0 0 32px rgba(255,30,30,.95),0 3px 20px rgba(0,0,0,.95);
color:#ff5f86;opacity:0;transition:opacity .18s;white-space:nowrap;letter-spacing:.03em;}
#death-msg.show{opacity:1;}
/* Cast bar */
#castbar{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);
width:340px;z-index:4;pointer-events:none;opacity:0;transition:opacity .2s;}
#castbar.show{opacity:1;}
#castbar .cast-label{font-size:13px;font-weight:600;color:var(--gold);margin-bottom:4px;text-align:center;
text-shadow:0 1px 8px rgba(0,0,0,.9);letter-spacing:.06em;}
#castbar .cast-track{height:18px;background:rgb(0 0 0/55%);border:1px solid rgb(255 255 255/18%);
border-radius:4px;overflow:hidden;}
#castbar .cast-fill{height:100%;background:linear-gradient(90deg,#ffd45a,#fff7a0);
width:0%;transition:width .06s linear;border-radius:4px;}
/* Scoreboard */
#scoreboard{position:fixed;inset:0;z-index:100;display:none;
background:rgb(0 0 0/75%);backdrop-filter:blur(6px);
align-items:center;justify-content:center;}
#scoreboard.show{display:flex;}
.score-card{background:#0d1022;border:1px solid rgb(255 255 255/18%);
border-radius:16px;padding:32px 40px;min-width:380px;max-width:480px;
box-shadow:0 32px 80px rgb(0 0 0/60%);}
.score-card h2{margin:0 0 4px;font-size:22px;text-align:center;color:var(--muted);letter-spacing:.12em;text-transform:uppercase;}
.score-grade{font-size:88px;font-weight:900;text-align:center;line-height:1;margin:4px 0;}
.score-grade.gAp{color:#ffd45a;text-shadow:0 0 50px rgba(255,212,90,.9);}
.score-grade.gA {color:#a8e563;}
.score-grade.gB {color:#85d8ff;}
.score-grade.gC {color:#fff;}
.score-grade.gD {color:#ff9f1c;}
.score-grade.gF {color:#ff5f86;}
.score-grade.gFm{color:#ff5f86;text-shadow:0 0 30px rgba(255,30,30,.6);}
.score-pts{text-align:center;font-size:14px;color:var(--muted);margin-bottom:16px;}
.score-pts strong{color:var(--text);font-size:18px;}
.score-breakdown{border-top:1px solid rgb(255 255 255/10%);padding-top:14px;margin-bottom:16px;}
.score-row{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);padding:2px 0;}
.score-row .val{font-weight:700;}
.score-row .val.pos{color:#a8e563;}
.score-row .val.neg{color:var(--danger);}
.score-actions{display:flex;gap:10px;justify-content:center;}
.score-actions button{padding:10px 24px;font-size:14px;}
Choose your role
βΆ DPS / Healer Standard play Β· Assigned Starsplinters π Light Holder Guide the raid with your light zone Β· 3 AI Starsplinters π‘ Add Tank Soak adds Β· Control the cone Β· 3 AI Starsplinters
Extra Action:
E
Defensive:
F
Choose Difficulty
Proceed to difficulty selection
EAB
F
DEFENSIVE UP
During re-integration, move to the
stack point at the north of the room.
stack point at the north of the room.
Time 0.0s
State Reintegration
Cycle 1 / 4
Score 0
β¬
LEFT
Starsplinter 1
RIGHT β‘
Starsplinter 2
β¬
LEFT
Starsplinter 3
MOVE IN
3
REINTEGRATION
NORMAL
A+
Score: 0
Time safe in light circle+0
Players sliced (β10 each)β0
Got sliced (β10 each)β0
Got munched (β5 each)β0
Midnight Falls (β100 each)β0
Got zapped (β100 each)β0
Wrong side on Starsplinter (β15 each)β0
Soaked without defensive (β5 each)β0
Adds hit raid β no EAB (β75 each)β0
Play Again
ZQSD β Z/S toward/away boss, Q/D strafe. White cylinder = you. Starsplinter assigned randomly each cycle.
Pause
Restart
Speed
Camera distance
Camera height
Show movement helper (Starsplinter target ring)
β Enabled: score Γ0.5 per second
β Enabled: score Γ0.5 per second
Show position markers
Coloured cylinders at raid spots
Coloured cylinders at raid spots
π₯ CHAOTIC FINAL PHASE ATTEMPT
Surprise callouts Β· Raid dies Β· 1.3pts/s Β· β½
Surprise callouts Β· Raid dies Β· 1.3pts/s Β· β½
Heaven and Hell
β GUILD CHAT
β Interface\AddOns Error
Close
π¬
Discord β Direct Message
Josh
Hey, you coming over later?
White: you Β |Β Blue: raid Β |Β
Gold: OT + cone Β |Β Yellow disc: light safe zone Β |Β
Cyan ring: your Starsplinter target Β |Β
Orientation A/B rotates shard pattern 45Β°
<script type="module">
import * as THREE from "https://unpkg.com/three@0.165.0/build/three.module.js";
// ββ DOM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const mount=document.getElementById("scene");
const clockEl=document.getElementById("clock"),stateEl=document.getElementById("state"),cycleEl=document.getElementById("cycle");
const scorePillEl=document.getElementById("scorepill");
const playBtn=document.getElementById("play"),restartBtn=document.getElementById("restart"),speedInput=document.getElementById("speed");
const camDistInput=document.getElementById("camDist"),camHeightInput=document.getElementById("camHeight");
const spFlashEl=document.getElementById("sp-flash");
const helperCheck=document.getElementById("helper"),chaoticCheck=document.getElementById("chaotic"),markersCheck=document.getElementById("markers");
const worldCupEl=document.getElementById("worldcup");
const guildChatEl=document.getElementById("guildchat"),gcMsgsEl=document.getElementById("gc-messages");
const discordEl=document.getElementById("discord-notif"),dcSenderEl=document.getElementById("dc-sender"),dcTextEl=document.getElementById("dc-text");
const raidcallEl=document.getElementById("raidcall"),movecallEl=document.getElementById("movecall");
const movecountdownEl=document.getElementById("movecountdown"),messageEl=document.getElementById("message");
const spSlotEls=[0,1,2].map(i=>document.getElementById(`sp${i}`));
const castbarEl=document.getElementById("castbar"),castfillEl=document.getElementById("castfill");
const bossCastbarEl=document.getElementById("boss-castbar"),bcbLabelEl=document.getElementById("bcb-label"),bcbFillEl=document.getElementById("bcb-fill");
const scoreboardEl=document.getElementById("scoreboard");
const luaErrorEl=document.getElementById("lua-error"),luaErrorBodyEl=document.getElementById("lua-error-body");
const deathMsgEl=document.getElementById("death-msg");
const reintHintEl=document.getElementById("reint-hint");
const penaltyFlashEl=document.getElementById("penalty-flash");
let prevScore=0,penaltyFlashExpiry=-999,spFlashExpiry=-999;
const modeSelectEl=document.getElementById("mode-select");
document.getElementById("lua-close-btn").addEventListener("click",()=>{luaErrorEl.classList.remove("show");luaErrorShowing=false;});
// ββ Phase / world ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const phase={
duration:90,reintegrationCast:5,
firstStarsplinter:16,starsplinterSpacing:20,
heavenSpacing:20,firstHeaven:22,
starsApplyGap:1,starsExplosionDelay:2,
moveOutDuration:1.0,returnDuration:1.45,
stunDuration:7,heavenRampDuration:4,heavenLaserDuration:4,
raidDirection:-1,raidAdvanceDegrees:110,
shaBaseInterval:0.4,shaSpeed:28,shaWaveSpeed:38,
tankConeDelay:0.5,tankConeDuration:0.5
};
const world={
roomRadius:285,bossRadius:36,stackDistance:134,
offTankDistance:126,lightRadius:60,shardLength:90,coneLength:109
};
const scale=0.052;
const splinterSides=[1,-1,1];
const splinterProfiles=[{tangential:46,radial:13},{tangential:58,radial:14},{tangential:56,radial:10}];
const immunityColors=[0xaad372,0xffd45a,0xff89c2];
const MARKER_COLORS=[0xffdd00,0xff8c00,0xaa44ff,0x44ff88];
const CAM_BACK=42,CAM_H=17.5;
// ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let time=0,last=performance.now(),running=false;
let playerSplinterForceExplodeAt=Infinity;
let selectedMode='normal';
let selectedRole='dps'; // 'dps' | 'light' | 'tank'
let eabKey='e',eabCooldownUntil=-Infinity,eabConePos=null,eabConeDir={x:0,y:1},eabConeExpiry=-Infinity,eabConeFired=false;
const tankEabWaveKillCycles=new Set(),checkedWavePenalties=new Set();
let defKey='f',defCharges=3,defExpiry=-Infinity;
let lightMovePenaltyExpiry=-Infinity;
let seed=5,lastAddTime=-999,nextAddId=1;
let adds=[],dyingAdds=[],waveSpawned=new Set();
let playerPos={x:0,y:-world.stackDistance};
let playerFacingDir={x:0,y:1}; // fixed facing: toward boss; only updates during movement phases
const keys={};
const cycleAssignments={},cycleOrientations={},cycleDelays={},cycleAdjustedDrops={},cycleFakeouts={},cycleSafeDrops={};
let nextWanderTime=20+Math.random()*12;
const checkedExplosions=new Set(),checkedLasers=new Set(),firedConeKills=new Set();
let messageExpiry=0;
let lastMunchTime=-999,lastMidnightTime=-999;
// Chaotic mode state
const deadAI=new Set();
let worldCupExpiry=-999,nextWorldCupTime=18;
const WCGOALS=['β½ World Cup Goal Scored!!!!','β½ GOOOOOAL!!!!','β½ HE SHOOTS HE SCORES!!!!','β½ UNBELIEVABLE SCENES!!!!'];
const chaoticSPLines=['RIGHT NO LEFT NO I MEAN LEFT','LEFT?? NO WAIT RIGHT!! LEFT!!!','WHICH SIDE AGAIN?? GO!! NOW!!','LEFT I THINK?? JUST MOVE!!'];
const GUILD_NAMES=['Ruby','Cydonion','Layelle','Boltern','Josh','Darktheist','Dizzypete','Catters','Max','Sessanelly','Slowbolts','Aratiel','Zafu','Convoke','Exality','Tarrackk','Unfortunate','Stigdk','Girlstreamer','Fred','Klkl','Mox','Melwalor','Anaxstrasza','Sekrai','Zivvs','Jaffa','Desolate','Khiraen','Nordisk','Denarr','Tuulip','Luca','Silccu','Mikhe','Rquin','Kalse','Sproodle','Shadowdager','Itrinx','Yaela','Fester'];
const GUILD_FIXED={'Sekrai':'bad joke','Jaffa':'thats cooked actually','Fester':'π«π·'};
function guildResp(name,fallback){return GUILD_FIXED[name]||fallback;};
const GUILD_RESPONSES=['omg','wtf','Good Riddance','wait what','nooo','the audacity','???','lmao','finally','I knew it','lol bye','carry diff','thank god','bruh','oof','not surprised','cya','drama queen','toxic tbh','expected','L + ratio','real ones know','this is fine','who tanks now','I called it','rip bozo','no way','was it something I said','lmaooo','the plot thickens','and they were our best dps too','ngl saw this coming','big yikes','free at last','don\'t let the door hit you','been months coming tbh','does officer know','wait when did they even join','pour one out','who does their role now','kicked or left?','classic','press F','F','insert surprised pikachu','honestly deserved','touched grass we assume','another one bites the dust','guild on life support at this point','RIP','we go again','bro really said bye mid progression'];
const DISCORD_NAMES=['Josh','Layelle','Mum','Sarah','Dave','Conor','Alex','Catters','Boltern'];
const DISCORD_MSGS=["Hey, you coming over later?","You left the fridge open","I'm thinking of quitting the game","Did you see the game last night?","Dinner's ready","You still owe me Β£20","Can you walk the dog?","Just found your cat on my roof","Meeting at 9am tomorrow","We need to talk","Your package arrived","Are you even listening?","I found your wallet","Stop ignoring me lol","Bro your car is blocking mine","Did you remember to take your meds?","Your parking ticket expires in 10 minutes","I think I scratched your car","The cat knocked everything off the shelf again","Can you pick up milk on the way home?","Why is there a horse in the front garden","Did you change the WiFi password??","Bro wake up new patch dropped","Your sister called again","I accidentally sent that to the group chat","The dishwasher is making that noise again","Did you pay the electricity bill","Your boss called the house phone","I think I failed my exam","The pizza arrived but I ate some of it","Why is there a tent in the living room","Someone keyed your car mate","I've been accepted into a cult, brb","Bro log off it's 3am","Your mum added me on Facebook","I'm outside your front door","Sorry wrong number","Did you feed the fish","There's a spider and I need you home NOW","I accidentally bought a boat","You've been selected for jury duty","I told everyone about that thing","Are you coming to Karen's wedding or not","My router keeps dropping, can I come over","Just remembered I still have your charger from 2019"];
const EASTER_EGGS=[
{name:'Darktheist',text:'it just keeps growing back, I gotta keep cuttin'},
{name:'Darktheist',text:"i'm not in it, I'm not in it bro!!! I'm not even in it!!!"},
{name:'Layelle',text:'use cooldowns anyone you have'},
{name:'Layelle',text:'I have 18 atonements rn'},
{name:'Jamesmarcus',text:"Why can't you soak?!?!"},
{name:'Josh',text:"I'm thinking of re-rolling for the 246th time this tier, shall I play monk?"},
{name:'Layelle',text:'turn on in game music this is SO good'},
{name:'Elizaslein',text:'can we take a break soon btw?'},
{name:'Zafu',text:"I'm thinking of pinging the entire raid team for the 15th time this evening. @LHF @LHF @LHF"},
{name:'Max',text:"I'm not the max you're thinking of."},
{name:'Ruby',text:'SOLO SOAKING'},
{name:'Ruby',text:'I am English'},
{name:'Ruby',text:'My class does no dps just none at all what the -',reply:{name:'Zafu',text:'Shut up.'}}
];
let easterEggSchedule=[],gcEggExpiry=-999,pendingGcReplies=[];
function buildEasterEggSchedule(){
easterEggSchedule=[];
const count=1+Math.floor(Math.random()*2);
const picks=[...EASTER_EGGS].sort(()=>Math.random()-.5).slice(0,count);
const base=[15+Math.random()*25,50+Math.random()*25];
picks.forEach((egg,i)=>easterEggSchedule.push({t:base[i],egg,fired:false}));}
function addGuildChatMsg(name,text,type='resp'){
const d=document.createElement('div');d.className='gc-msg';
d.innerHTML=`${name}: ${text}`;
gcMsgsEl.appendChild(d);}
const CHAOS_MOVE_CALLS=['MOVE!!','WAIT!!','NO WAIT!!','GO!!','STOP!!','ACTUALLY MOVE!!'];
const DEATH_REACTIONS=["I wasn't even in it!","DESK SLAM","What are you doing?!?!?!","bro WHAT","ARE YOU KIDDING ME??","MY KEYBOARD","I HATE THIS GAME","hello???? wtf","unbelievable","that's a rage DC","NOT MY FAULT","I WAS MOVING","LAG","REPORTED","are you serious rn","TOUCH GRASS","I can't believe this","my poor chair"];
const LUA_ERRORS=[
`Message: Interface\\FrameXML\\UIParent.lua:3092: attempt to index nil value 'self'\nstack traceback:\n[C]: ?\nInterface\\FrameXML\\UIParent.lua:3092: in function 'SetPoint'\nInterface\\AddOns\\RaidFrameHelper\\core.lua:887: in local 'UpdateRaidFrame'\nInterface\\AddOns\\RaidFrameHelper\\core.lua:1204: in function <...>\n...\n(tail call): ?`,
`Message: Interface\\AddOns\\DBM-Core\\DBM-Core.lua:7341: attempt to perform arithmetic on nil value (global 'timerOffset')\nstack traceback:\n[C]: ?\nInterface\\AddOns\\DBM-Core\\DBM-Core.lua:7341: in method 'Start'\nInterface\\AddOns\\DBM-Raids-DF\\LuraP4.lua:448: in function 'COMBAT_LOG_EVENT_UNFILTERED'\n...\n(tail call): ?`,
`Message: Interface\\AddOns\\WeakAuras\\RegionTypes\\Text.lua:512: table index is NaN\nstack traceback:\n[C]: ?\nInterface\\AddOns\\WeakAuras\\RegionTypes\\Text.lua:512: in function 'SetText'\nInterface\\AddOns\\WeakAuras\\WeakAuras.lua:2991: in function 'UpdateTriggerState'\n...\n(tail call): ?`,
`Message: Interface\\FrameXML\\ActionButton.lua:234: Cannot find a library instance of 'LibStub-1.0'\nstack traceback:\n[C]: ?\nInterface\\AddOns\\ElvUI\\Core\\API.lua:234: in function 'Embed'\nInterface\\AddOns\\ElvUI\\Modules\\UnitFrames\\UF.lua:881: in function 'Initialize'\n...\n(tail call): ?`,
`Message: Interface\\AddOns\\Plater\\Plater.lua:9042: attempt to call nil (method 'GetName')\nstack traceback:\n[C]: ?\nInterface\\AddOns\\Plater\\Plater.lua:9042: in method 'ApplyProfile'\nInterface\\AddOns\\Plater\\Plater.lua:1337: in function 'ADDON_LOADED'\n...\n(tail call): ?`,
`Message: Interface\\AddOns\\Shadowed Unit Frames\\modules\\auras.lua:177: bad argument #1 to 'pairs' (table expected, got nil)\nstack traceback:\n[C]: in function 'pairs'\nInterface\\AddOns\\SUF\\modules\\auras.lua:177: in function 'UpdateAuras'\nInterface\\AddOns\\SUF\\units\\player.lua:44: in function 'FullUpdate'\n...\n(tail call): ?`];
const OFFLINE_RESPONSES=["during a pull?","ffs not again","every. single. time.","disconnect or rage?","classic","rip","pulled the plug lmao","not again bro","bruh","come on man","always at the worst time","we needed them"];
let chaosMoveText='WAIT!!',nextChaosMoveFlip=-999;
let gcQueue=[],gcDramaStart=-999,gcDramaActive=false,nextGuildDrama=22;
let nextOfflineTime=45;
let dcExpiry=-999,nextDcTime=35;
let luaErrorShowing=false,nextLuaErrorTime=28;
let deathMsgExpiry=-999,lastDeathMsgTime=-999;
const CHAOTIC_CORNERS=[
{top:'90px',left:'16px',right:'auto',bottom:'auto'},
{top:'90px',right:'270px',left:'auto',bottom:'auto'},
{bottom:'120px',left:'16px',top:'auto',right:'auto'},
{bottom:'120px',right:'270px',left:'auto',top:'auto'}];
// Score
let score=0;
const penalties={sliceOthers:0,sliced:0,munched:0,midnight:0,zapped:0,wrongSide:0};
let safeAccum=0;
let scoreboardShown=false;
window.addEventListener("keydown",e=>{
if("zqsd".includes(e.key.toLowerCase()))e.preventDefault();
keys[e.key.toLowerCase()]=true;
if(selectedRole==='tank'&&running&&e.key.toLowerCase()===eabKey&&time>eabCooldownUntil
&&time>phase.reintegrationCast+phase.stunDuration){
eabConePos={...playerPos};eabConeDir={...playerFacingDir};
eabConeExpiry=time+0.5;eabCooldownUntil=time+18;eabConeFired=false;
const ac=activeStarsplinterCycle(time);
if(ac&&waveSpawned.has(ac.index))tankEabWaveKillCycles.add(ac.index);}
if(selectedRole==='tank'&&running&&e.key.toLowerCase()===defKey&&defCharges>0
&&time>phase.reintegrationCast+phase.stunDuration){
defCharges--;defExpiry=time+6;
updateDefUI();}});
window.addEventListener("keyup",e=>{keys[e.key.toLowerCase()]=false;});
window.addEventListener("blur",()=>Object.keys(keys).forEach(k=>keys[k]=false));
function getCycleAssignment(i){if(selectedRole!=='dps')return -1;if(!(i in cycleAssignments))cycleAssignments[i]=Math.floor(Math.random()*3);return cycleAssignments[i];}
function getCycleOrientation(i){if(!(i in cycleOrientations))cycleOrientations[i]=Math.random()<.5?1:0;return cycleOrientations[i];}
const cyclePlayerOrientations={};
function getCyclePlayerOrientation(i){if(!(i in cyclePlayerOrientations))cyclePlayerOrientations[i]=Math.random()<.5?1:0;return cyclePlayerOrientations[i];}
// SP2 bot sometimes hesitates ~1s before moving out (50% chance per cycle)
function getCycleSP2Delay(i){if(!(i in cycleDelays))cycleDelays[i]=0.7+Math.random()*.6;return cycleDelays[i];}
// Per-cycle fakeout: AI SP0/SP1 has 20% chance to move wrong way briefly then correct
function getCycleFakeout(ci,drops){
if(ci in cycleFakeouts)return cycleFakeouts[ci];
const f={};
[0,1].forEach(si=>{
if(Math.random()<0.2){
f[si]={wrongDrop:drops[1-si],dur:0.4+Math.random()*.35};}});
cycleFakeouts[ci]=f;return f;}
// Find a safe drop position where no shard ray hits the stack
function findSafeDrop(defaultDrop,stackPos,orientB,safeR=18){
const check=pos=>{const angs=shardAnglesForDrop(stackPos,pos,orientB);for(const a of angs)if(shardHitsPos(pos,a,stackPos,safeR))return false;return true;};
if(check(defaultDrop))return defaultDrop;
const rv=norm2(stackPos),tv={x:-rv.y,y:rv.x};
for(let off=8;off<=64;off+=8)for(const s of[1,-1]){const c=add2(defaultDrop,mul2(tv,off*s));if(check(c))return c;}
return defaultDrop;}
function getCycleSafeDrop(ci,si,stackPos,drops,orientB){
const key=`${ci}-${si}`;
if(!(key in cycleSafeDrops))cycleSafeDrops[key]=findSafeDrop(drops[si],stackPos,orientB);
return cycleSafeDrops[key];}
// SP2 bot checks SP0's shards and picks a safe alternative drop once per cycle
function getAdjustedSP2Drop(cycleIdx,stackPos,drops,orientB){
if(cycleIdx in cycleAdjustedDrops)return cycleAdjustedDrops[cycleIdx];
const sp0drop=drops[0],sp0angles=shardAnglesForDrop(stackPos,sp0drop,orientB);
const hitR=18;
function safe(pos){for(const ang of sp0angles){if(shardHitsPos(sp0drop,ang,pos,hitR))return false;}return true;}
const maxR=world.lightRadius-4,r=norm2(stackPos),tan={x:-r.y,y:r.x};
function clampL(p){const rel=sub2(p,stackPos),d=len2(rel);return d>maxR?add2(stackPos,mul2(norm2(rel),maxR)):p;}
const def=drops[2];let chosen=def;
if(!safe(def)){
// Try radial/tangential shifts of increasing magnitude; commit to first safe spot
const shifts=[[-15,0],[15,0],[0,12],[0,-12],[-15,12],[15,12],[-20,0],[0,20],[-15,-12],[15,-12]];
for(const[dr,dt]of shifts){
const c=clampL(add2(def,add2(mul2(r,dr),mul2(tan,dt))));
if(safe(c)){chosen=c;break;}}}
cycleAdjustedDrops[cycleIdx]=chosen;return chosen;}
// ββ Three.js βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const scene=new THREE.Scene();
scene.background=new THREE.Color(0x02030a);
scene.fog=new THREE.Fog(0x02030a,18,50);
const camera=new THREE.PerspectiveCamera(32,innerWidth/innerHeight,.1,200);
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setSize(innerWidth,innerHeight);
renderer.setPixelRatio(Math.min(2,devicePixelRatio||1));
mount.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0x9bbdff,0x120816,1.2));
const keyLight=new THREE.DirectionalLight(0xffffff,1.6);keyLight.position.set(8,14,7);scene.add(keyLight);
// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function w2v(p,y=0){return new THREE.Vector3(p.x*scale,y,p.y*scale);}
function polar(a,r){return{x:Math.cos(a)*r,y:Math.sin(a)*r};}
function add2(a,b){return{x:a.x+b.x,y:a.y+b.y};}
function sub2(a,b){return{x:a.x-b.x,y:a.y-b.y};}
function mul2(a,n){return{x:a.x*n,y:a.y*n};}
function len2(a){return Math.hypot(a.x,a.y);}
function norm2(a){const l=len2(a)||1;return{x:a.x/l,y:a.y/l};}
function angleOf(p){return Math.atan2(p.y,p.x);}
function normalizeAngle(a){while(a<=-Math.PI)a+=Math.PI*2;while(a>Math.PI)a-=Math.PI*2;return a;}
function angularDelta(f,t){return normalizeAngle(t-f);}
function rotate2(v,a){const c=Math.cos(a),s=Math.sin(a);return{x:v.x*c-v.y*s,y:v.x*s+v.y*c};}
function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v));}
function lerp(a,b,p){return a+(b-a)*p;}
function smooth(p){p=clamp(p,0,1);return p*p*(3-2*p);}
function rand(){seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;}
function makeRing(radius,color,opacity,tube=0.04){
const m=new THREE.Mesh(new THREE.TorusGeometry(radius*scale,tube,12,96),
new THREE.MeshBasicMaterial({color,transparent:true,opacity}));
m.rotation.x=Math.PI/2;return m;}
function makeCyl(color,emissive=0,rT=0.19,rB=0.19,h=0.68){
return new THREE.Mesh(new THREE.CylinderGeometry(rT,rB,h,8),
new THREE.MeshStandardMaterial({color,emissive,roughness:.6,metalness:.1}));}
function orientCylBetween(m,s3,e3){
const dir=e3.clone().sub(s3),len=dir.length();
if(len<.001){m.visible=false;return;}
m.scale.y=len;m.position.copy(s3.clone().add(e3).multiplyScalar(.5));
m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),dir.normalize());m.visible=true;}
function showMessage(text,type,dur=3.2){
messageEl.textContent=text;messageEl.className=`show ${type}`;messageExpiry=time+dur;}
function penalize(type,amount,text,msgType='danger'){
score-=amount;penalties[type]=(penalties[type]||0)+amount;
showMessage(text,msgType);}
// ββ Timing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function raidAngleAt(t){
let a=-Math.PI/2;
const moveDelay=1.0; // raid holds still for 1s after Heaven cast starts
for(let i=0;i<3;i++){
const ht=phase.firstHeaven+i*phase.heavenSpacing;if(t<=ht)break;
const dur=phase.heavenRampDuration+phase.heavenLaserDuration;
a+=phase.raidDirection*phase.raidAdvanceDegrees*Math.PI/180*clamp((t-ht-moveDelay)/(dur-moveDelay),0,1);
if(t>=ht+dur)a=-Math.PI/2+phase.raidDirection*(i+1)*phase.raidAdvanceDegrees*Math.PI/180;}
return a;}
function raidPosAt(t){return polar(raidAngleAt(t),world.stackDistance);}
function currentHeaven(t){
for(let i=0;i<3;i++){const s=phase.firstHeaven+i*phase.heavenSpacing,ls=s+phase.heavenRampDuration,e=ls+phase.heavenLaserDuration;
if(t>=s&&t<=e)return{index:i,start:s,laserStart:ls,end:e};}return null;}
function heavenCountdownAt(t){
for(let i=0;i<3;i++){const s=phase.firstHeaven+i*phase.heavenSpacing;if(t>=s-3&&t=s-1.2&&t<=s+9)return{index:c,start:s,end:s+9};}return null;}
function directedRaidDelta(f,t){return normalizeAngle((t-f)*phase.raidDirection);}
function stunLiftAt(t){
const s=phase.reintegrationCast,e=s+phase.stunDuration;if(te)return 0;
const dt=t-s,p=dt<=5.5?smooth(dt/5.5):1-smooth((dt-5.5)/1.5);return p*5.8;}
function coneActive(t){
const c=activeStarsplinterCycle(t);if(!c)return false;
const s=c.start+phase.tankConeDelay,e=s+phase.tankConeDuration;return t>=s&&t<=e;}
// ββ Splinter layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function splinterLayout(stackPos,orientB){
const r=norm2(stackPos);
return splinterSides.map((side,i)=>{
const t={x:-r.y*side,y:r.x*side},p=splinterProfiles[i];
let d=add2(stackPos,add2(mul2(t,p.tangential),mul2(r,p.radial)));
if(orientB){const rel=sub2(d,stackPos);d=add2(stackPos,rotate2(rel,28*Math.PI/180*side));}
const rel=sub2(d,stackPos),dist=len2(rel),maxR=world.lightRadius-4;
if(dist>maxR)d=add2(stackPos,mul2(norm2(rel),maxR));
return d;});}
function shardAnglesForDrop(stackPos,drop,orientB){
const da=angleOf(sub2(stackPos,drop)),ex=orientB?Math.PI/4:Math.PI/6;
// orientA: one ray points directly toward the stack (da+0); orientB: rotated 45Β°
return Array.from({length:6},(_,i)=>da+ex+i*Math.PI/3);}
function splinterState(t,ci,si,stackPos,drop){
const apply=phase.firstStarsplinter+ci*phase.starsplinterSpacing+si*phase.starsApplyGap;
const explode=apply+phase.starsExplosionDelay,retEnd=explode+phase.returnDuration;
if(tretEnd)return null;
let pos=stackPos,sv=false,ex=false;
if(t=.68;
}else if(tworld.shardLength)return false;
return len2(sub2(target,add2(drop,mul2(dir,along))))polar(-Math.PI/2+phase.raidDirection*i*phase.raidAdvanceDegrees*Math.PI/180,world.stackDistance));
function markerArrivalTime(i){
if(i===0)return phase.firstHeaven;
return phase.firstHeaven+(i-1)*phase.heavenSpacing+phase.heavenRampDuration+phase.heavenLaserDuration;}
const worldMarkers=wmpPositions.map((pos,i)=>{
const col=MARKER_COLORS[i];
const mat=new THREE.MeshStandardMaterial({color:col,emissive:col,emissiveIntensity:.35,transparent:true,opacity:.5,roughness:.4});
const mesh=new THREE.Mesh(new THREE.CylinderGeometry(.44,.52,2.1,16),mat);
mesh.position.copy(w2v(pos,1.05));scene.add(mesh);
const ring=makeRing(16,col,.85,.055);ring.position.copy(w2v(pos,.03));scene.add(ring);
return{mesh,ring,mat,ringMat:ring.material};});
// ββ Floor / room ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const floorMesh=new THREE.Mesh(new THREE.CylinderGeometry(world.roomRadius*scale,world.roomRadius*scale,.05,160),
new THREE.MeshStandardMaterial({color:0x05060d,roughness:.92,metalness:.02}));
floorMesh.position.y=-.04;scene.add(floorMesh);
scene.add(makeRing(world.roomRadius,0xffffff,.08,.022));
const floorLineMat=new THREE.LineBasicMaterial({color:0x85d8ff,transparent:true,opacity:.18});
for(let r=55;r<=world.roomRadius-30;r+=38){const rg=makeRing(r,0x85d8ff,.14,.010);rg.position.y=.025;scene.add(rg);}
for(let i=0;i<24;i++){const a=i*Math.PI/12;
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([w2v(polar(a,42),.035),w2v(polar(a,world.roomRadius-10),.035)]),floorLineMat));}
// ββ Boss ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const boss=new THREE.Mesh(new THREE.SphereGeometry(world.bossRadius*scale,32,18),
new THREE.MeshStandardMaterial({color:0x6f42c1,emissive:0x2c104f,roughness:.4}));
boss.position.set(0,world.bossRadius*scale,0);scene.add(boss);
// ββ Light zone ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const lightRing=makeRing(world.lightRadius,0x55ccff,1.0,.10);scene.add(lightRing);
const lightRingGlow=makeRing(world.lightRadius,0x88ddff,.28,.22);scene.add(lightRingGlow);
const stackGlow=makeRing(7,0xff9900,.85,.048);scene.add(stackGlow);
// Warm disc: red inner fading to yellow β two layered discs
const lightDiscRed=new THREE.Mesh(new THREE.CircleGeometry(world.lightRadius*scale*.55,64),
new THREE.MeshBasicMaterial({color:0xff2200,transparent:true,opacity:.32,depthWrite:false}));
lightDiscRed.renderOrder=2;lightDiscRed.rotation.x=-Math.PI/2;lightDiscRed.visible=false;scene.add(lightDiscRed);
const lightDisc=new THREE.Mesh(new THREE.CircleGeometry(world.lightRadius*scale,64),
new THREE.MeshBasicMaterial({color:0xffaa00,transparent:true,opacity:.26,depthWrite:false}));
lightDisc.renderOrder=1;lightDisc.rotation.x=-Math.PI/2;lightDisc.visible=false;scene.add(lightDisc);
// ββ OT cylinder + cone ββββββββββββββββββββββββββββββββββββββββββββββββββββ
const otCyl=makeCyl(0xffd45a,0x553600,.21,.21,.72);scene.add(otCyl);
const otConeMesh=(()=>{
const ha=Math.PI/4,n=24,r=world.coneLength*scale,pts=[];
for(let i=0;i{
for(let j=0;j{const r=makeRing(12,0x69d6ff,.8,.035);r.visible=false;scene.add(r);return r;});
let beamTip2d={x:0,y:0};
// ββ Shard cylinders βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Shard: tapered β thick at player end (radiusBottom/-Y), needle tip at far end (radiusTop/+Y)
// Two layers: bright cyan inner + dark blue outer glow
const shardInnerMat=new THREE.MeshBasicMaterial({color:0xaaeeff,transparent:true,opacity:.97,depthWrite:false});
const shardOuterMat=new THREE.MeshBasicMaterial({color:0x1033cc,transparent:true,opacity:.52,depthWrite:false});
const shardInners=Array.from({length:18},()=>{
const m=new THREE.Mesh(new THREE.CylinderGeometry(.008,.11,1,7),shardInnerMat);
m.visible=false;m.renderOrder=2;scene.add(m);return m;});
const shardOuters=Array.from({length:18},()=>{
const m=new THREE.Mesh(new THREE.CylinderGeometry(.028,.20,1,7),shardOuterMat);
m.visible=false;m.renderOrder=1;scene.add(m);return m;});
function setShardCyl(idx,a,b){
orientCylBetween(shardInners[idx],w2v(a,.12),w2v(b,.12));
orientCylBetween(shardOuters[idx],w2v(a,.12),w2v(b,.12));}
// ββ Pulse triangles βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const triGeom=new THREE.BufferGeometry();
triGeom.setAttribute("position",new THREE.Float32BufferAttribute([0,.04,.48,-.22,.04,-.25,.22,.04,-.25],3));
triGeom.computeVertexNormals();
const pulseMat=new THREE.MeshBasicMaterial({color:0xff2a43,side:THREE.DoubleSide});
const pulseTriangles=Array.from({length:18},()=>{const t=new THREE.Mesh(triGeom,pulseMat);t.visible=false;scene.add(t);return t;});
// ββ Splinter markers ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const splinterMeshes=[0,1,2].map(()=>{
const m=new THREE.Mesh(new THREE.SphereGeometry(.28,18,12),
new THREE.MeshStandardMaterial({color:0xff5f86,emissive:0x4f0619,roughness:.35}));
scene.add(m);return m;});
// ββ Add materials βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const mPurp=new THREE.MeshStandardMaterial({color:0x9b69d7,emissive:0x211039,roughness:.45});
const mPink=new THREE.MeshStandardMaterial({color:0xff5f86,emissive:0x3d0614,roughness:.45});
const mWhite=new THREE.MeshStandardMaterial({color:0xffffff,emissive:0x777777,roughness:.25});
// ββ Add logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function offTankPos(sp){return polar(angleOf(sp),world.offTankDistance);}
function addThreatensRaidPath(a,heaven,t){
if(!heaven)return false;
const pl=phase.raidAdvanceDegrees*Math.PI/180,sa=-Math.PI/2+phase.raidDirection*heaven.index*pl,ea=sa+phase.raidDirection*pl;
const addA=angleOf(a.pos),ahead=directedRaidDelta(raidAngleAt(t),addA),rem=Math.max(0,directedRaidDelta(raidAngleAt(t),ea));
const pp=directedRaidDelta(sa,addA),r=len2(a.pos),ttr=(world.stackDistance-r)/Math.max(1,a.speed);
return ahead>=0&&ahead<=rem+10*Math.PI/180&&pp>=-0.001&&pp<=pl+0.001&&r-.7&&ttr<(heaven.end-t)+2.0;}
function immunityPos(t,sp){
const h=currentHeaven(t),home=add2(sp,mul2(norm2(sp),-28));if(!h)return home;
const threat=adds.filter(a=>addThreatensRaidPath(a,h,t)).sort((a,b)=>Math.abs(world.stackDistance-len2(a.pos))-Math.abs(world.stackDistance-len2(b.pos)))[0];
const rad=norm2(sp),fs=h.index===1?-1:1,fb=add2(home,mul2({x:-rad.y*fs,y:rad.x*fs},48));
const intercept=threat?add2(threat.pos,mul2(threat.dir,20)):fb;
const mp=smooth(clamp((t-h.start)/2.2,0,1)),rp=smooth(clamp((t-(h.end-1.4))/1.4,0,1));
const active={x:lerp(home.x,intercept.x,mp),y:lerp(home.y,intercept.y,mp)};
return{x:lerp(active.x,home.x,rp),y:lerp(active.y,home.y,rp)};}
function soakAdd(a,t,soaker){
if(t-a.lastSoakedAt<1.2)return;
// 10% chance to nudge direction on first soak
if(t>=a.soakedUntil&&Math.random()<0.1){
const away=norm2(sub2(a.pos,soaker));
a.dir=norm2(rotate2(a.dir,clamp(angularDelta(angleOf(a.dir),angleOf(away)),-Math.PI/4,Math.PI/4)));}
a.soakedUntil=t+1.2;a.lastSoakedAt=t;}
function spawnAdd(t,wave,fixedAngle){
const a=fixedAngle!==undefined?fixedAngle:rand()*Math.PI*2;
const s=polar(a,world.bossRadius+8);
const mesh=new THREE.Mesh(new THREE.BoxGeometry(wave?.95:.80,wave?.22:.18,wave?.60:.50),wave?mPink:mPurp);
scene.add(mesh);
adds.push({id:nextAddId++,pos:s,dir:{x:Math.cos(a),y:Math.sin(a)},
speed:(wave?phase.shaWaveSpeed:phase.shaSpeed)*(0.82+rand()*.36),
wave,wobble:rand()*Math.PI*2,soakedUntil:0,lastSoakedAt:-999,mesh});}
function updateAdds(t,dt,sp,otp,ip){
if(t=cycle.start&&!waveSpawned.has(cycle.index)){for(let i=0;i<36;i++)spawnAdd(t,true,i*(Math.PI/18));waveSpawned.add(cycle.index);}
if(t-lastAddTime>=phase.shaBaseInterval){spawnAdd(t,false);lastAddTime=t;}
for(const a of adds){
a.pos=add2(a.pos,mul2(a.dir,a.speed*dt));
if(selectedRole==='tank'){
if(len2(sub2(a.pos,playerPos))<22){
if(t-a.lastSoakedAt>=1.2&&t>defExpiry){
score-=5;penalties.noDefensive=(penalties.noDefensive||0)+5;
showMessage('NO DEFENSIVE! β5','bad',2);}
soakAdd(a,t,playerPos);}
}else if(len2(sub2(a.pos,otp))<22)soakAdd(a,t,otp);
if(currentHeaven(t)&&len2(sub2(a.pos,ip))<20)soakAdd(a,t,ip);
// Direction only changes via soakAdd
a.mesh.material=t{const keep=len2(a.pos)7;if(!keep)scene.remove(a.mesh);return keep;});}
// ββ Cone kills ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function fireConekills(t,conePos,coneFwd){
const otp=conePos,fwd=coneFwd;
adds=adds.filter(a=>{
const rel=sub2(a.pos,otp);
const along=rel.x*fwd.x+rel.y*fwd.y;
if(along<0||along>world.coneLength)return true;
const side=Math.abs(rel.x*(-fwd.y)+rel.y*fwd.x);
if(side>along*Math.tan(Math.PI/4)+12)return true;
a.mesh.material=new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:1});
dyingAdds.push({pos:{...a.pos},startT:t,mesh:a.mesh});
return false;});}
function updateConeKills(t,stackPos){
if(selectedRole==='tank'){
if(!eabConePos||t>=eabConeExpiry||t<=phase.reintegrationCast+phase.stunDuration)return;
if(!eabConeFired){eabConeFired=true;fireConekills(t,eabConePos,eabConeDir);}
}else{
if(!coneActive(t))return;
const c=activeStarsplinterCycle(t);if(!c)return;
const key=`cone-${c.index}`;
if(firedConeKills.has(key))return;
firedConeKills.add(key);
fireConekills(t,offTankPos(stackPos),norm2(mul2(stackPos,-1)));
}}
function updateDyingAdds(t){
for(let i=dyingAdds.length-1;i>=0;i--){
const d=dyingAdds[i],p=clamp((t-d.startT)/.35,0,1);
if(p>=1){scene.remove(d.mesh);dyingAdds.splice(i,1);continue;}
d.mesh.position.copy(w2v(d.pos,.42+p*.9));
d.mesh.material.opacity=1-p;
d.mesh.scale.setScalar(1+p*2.5);}}
// ββ Player ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function updatePlayer(t,dt,stackPos,liftY){
const dur=t>=phase.reintegrationCast&&t<=phase.reintegrationCast+phase.stunDuration;
const aft=t>phase.reintegrationCast+phase.stunDuration;
playerCyl.visible=playerRing.visible=dur||aft;if(!playerCyl.visible)return;
// Update facing only during stun and Heaven movement β locked otherwise
if(dur||currentHeaven(t)){
const toBoss={x:-playerPos.x,y:-playerPos.y};
if(len2(toBoss)>1)playerFacingDir=norm2(toBoss);}
if(aft&&running){
const spd=(selectedRole==='light'?45.5:35)*dt,fwd=playerFacingDir,rgt={x:-fwd.y,y:fwd.x};
if(keys['z'])playerPos=add2(playerPos,mul2(fwd,spd));
if(keys['s'])playerPos=add2(playerPos,mul2({x:-fwd.x,y:-fwd.y},spd));
if(keys['q'])playerPos=add2(playerPos,mul2({x:fwd.y,y:-fwd.x},spd));
if(keys['d'])playerPos=add2(playerPos,mul2(rgt,spd));}
if(dur)playerPos={...stackPos};
const baseY=.34+(dur?liftY:0);
playerCyl.position.copy(w2v(playerPos,baseY));
playerRing.position.copy(w2v(playerPos,.04+(dur?liftY:0)));}
const aiWander=Array.from({length:17},()=>({active:false,startTime:0,endTime:0,fromPos:{x:0,y:0},target:{x:0,y:0}}));
// ββ AI players ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function updateAIPlayers(t,stackPos,liftY){
const dur=t>=phase.reintegrationCast&&t<=phase.reintegrationCast+phase.stunDuration;
const aft=t>phase.reintegrationCast+phase.stunDuration;
const show=dur||aft;
const cycle=activeStarsplinterCycle(t);
const playerSP=cycle?getCycleAssignment(cycle.index):-1;
const orientB=cycle?getCycleOrientation(cycle.index):0;
const drops=cycle?splinterLayout(stackPos,orientB):null;
// Trigger random wander for a non-SP AI
if(aft&&running&&t>=nextWanderTime){
const cands=aiPlayers.map((a,i)=>i).filter(i=>aiPlayers[i].spIndex===null&&!aiWander[i].active);
if(cands.length){
const idx=cands[Math.floor(Math.random()*cands.length)];
const ang=Math.random()*Math.PI*2,dist=world.lightRadius*.5+Math.random()*world.lightRadius*.3;
Object.assign(aiWander[idx],{active:true,startTime:t,endTime:t+0.9+Math.random()*.6,
fromPos:{...aiPlayers[idx].currentPos},target:add2(stackPos,polar(ang,dist))});}
nextWanderTime=t+12+Math.random()*15;}
aiPlayers.forEach((ai,i)=>{
ai.mesh.visible=show;if(!show)return;
let pos;
if(ai.spIndex!==null&&cycle&&ai.spIndex!==playerSP){
const eDrop=ai.spIndex===2?getAdjustedSP2Drop(cycle.index,stackPos,drops,orientB):getCycleSafeDrop(cycle.index,ai.spIndex,stackPos,drops,orientB);
const st=splinterState(t,cycle.index,ai.spIndex,stackPos,eDrop);
pos=st?st.pos:add2(stackPos,ai.baseOffset);
// Chaotic fakeout: briefly move toward wrong side then self-correct
if(chaoticCheck.checked&&ai.spIndex<2&&drops){
const fo=getCycleFakeout(cycle.index,drops)[ai.spIndex];
if(fo){const applyI=phase.firstStarsplinter+cycle.index*phase.starsplinterSpacing+ai.spIndex*phase.starsApplyGap;
if(t>=applyI&&t=tot){w.active=false;}
else if(elc.visible=false);shardOuters.forEach(c=>c.visible=false);
pulseTriangles.forEach(p=>p.visible=false);
splinterMeshes.forEach(m=>m.visible=false);
targetRing.visible=false;
const cycle=activeStarsplinterCycle(t);if(!cycle)return;
const playerSP=getCycleAssignment(cycle.index);
const orientB=getCycleOrientation(cycle.index);
const drops=splinterLayout(stackPos,orientB);
let li=0;
for(let i=0;i<3;i++){
if(i===playerSP){
const apply=phase.firstStarsplinter+cycle.index*phase.starsplinterSpacing+i*phase.starsApplyGap;
const explode=apply+phase.starsExplosionDelay,retEnd=explode+phase.returnDuration;
// If munched during active splinter, shift time forward to explosion
const tEff=(playerSplinterForceExplodeAt=apply&&tretEnd){li+=6;continue;}
// Show target ring only if helper enabled
if(helperCheck.checked&&tEff=apply&&tEff=explode&&tEff=h.laserStart);
if(!active){
beamCyl.visible=beamGlowCyl.visible=false;
beamPulses.forEach(r=>r.visible=false);
laserFlashMat.opacity=0;return;}
const angle=raidAngleAt(t)-phase.raidDirection*40*Math.PI/180;
beamTip2d=polar(angle,world.stackDistance);
// Beam from top of boss sphere to floor at tip
const bossTop=new THREE.Vector3(0,world.bossRadius*scale*2,0);
const tipFloor=w2v(beamTip2d,0);
orientCylBetween(beamCyl,bossTop,tipFloor);
orientCylBetween(beamGlowCyl,bossTop,tipFloor);
beamCyl.visible=beamGlowCyl.visible=true;
// Pulse rings at tip
const pulse=((t-h.laserStart)%.8)/.8;
beamPulses.forEach((r,i)=>{
r.visible=true;const rp=(pulse+i/3)%1;
r.position.copy(w2v(beamTip2d,.08));r.scale.setScalar(.55+rp*2.2);r.material.opacity=.72*(1-rp);});
// Flash room red once per second
const flashPhase=((t-h.laserStart)%1.0);
laserFlashMat.opacity=flashPhase<.1?smooth(flashPhase/.1)*.22:flashPhase<.22?smooth(1-(flashPhase-.1)/.12)*.22:0;}
// ββ OT cone βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function applyConeWave(mesh,progress){
// Scale along forward axis (X) to animate wave sweeping out
const p=clamp(progress,0,1);
mesh.scale.set(p,1,1);
mesh.material.opacity=0.55*(1-p*0.6);}
function updateOTCone(t,stackPos){
if(selectedRole==='tank'){
const eabActive=eabConePos&&tphase.reintegrationCast+phase.stunDuration;
otConeMesh.visible=!!eabActive;
if(eabActive){
const p=(t-(eabConeExpiry-0.5))/0.5;
otConeMesh.position.copy(w2v(eabConePos,0));
const fwd=new THREE.Vector3(eabConeDir.x,0,eabConeDir.y).normalize();
otConeMesh.quaternion.setFromUnitVectors(new THREE.Vector3(1,0,0),fwd);
applyConeWave(otConeMesh,p);}
return;}
const active=coneActive(t)&&t>phase.reintegrationCast+phase.stunDuration;
otConeMesh.visible=active;if(!active){otConeMesh.scale.set(1,1,1);return;}
const c=activeStarsplinterCycle(t);
const coneStart=c?c.start+phase.tankConeDelay:t;
const p=(t-coneStart)/phase.tankConeDuration;
const otp=offTankPos(stackPos),n=norm2(otp);
otConeMesh.position.copy(w2v(otp,0));
const to=new THREE.Vector3(-n.x,0,-n.y).normalize();
otConeMesh.quaternion.setFromUnitVectors(new THREE.Vector3(1,0,0),to);
applyConeWave(otConeMesh,p);}
// ββ World markers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function updateWorldMarkers(t){
if(!markersCheck.checked){worldMarkers.forEach(m=>{m.mesh.visible=m.ring.visible=false;});return;}
const stunEnd=phase.reintegrationCast+phase.stunDuration;
worldMarkers.forEach((m,i)=>{
const arr=markerArrivalTime(i),f0=arr-.5,f1=arr+1.8;
if(t>=f1||(i===0&&t>stunEnd)){m.mesh.visible=m.ring.visible=false;return;}
m.mesh.visible=m.ring.visible=true;
const alpha=t>=f0?1-smooth((t-f0)/(f1-f0)):1;
m.mat.opacity=.5*alpha;m.ringMat.opacity=.85*alpha;
const isNext=i>0&&t=markerArrivalTime(i-1));
m.mat.emissiveIntensity=isNext?.35+.2*Math.sin(t*5):.35;});}
// ββ Stun βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function updateStun(t){
const s=phase.reintegrationCast,e=s+phase.stunDuration,active=t>=s&&t<=e;
stunField.visible=active;if(!active)return;
const pulse=.5+.5*Math.sin((t-s)*Math.PI*3.4);
stunFieldMat.color.setHex(pulse>.5?0xff2a43:0x5cc8ff);stunFieldMat.opacity=.13+pulse*.16;}
// ββ Boss cast bar (Heaven & Hell) βββββββββββββββββββββββββββββββββββββββββ
const _bossTop=new THREE.Vector3(0,world.bossRadius*scale*0.5,0);
function updateStunHints(t){
const s=phase.reintegrationCast,e=s+phase.stunDuration;
// North hint: fade in t=0..0.5, hold, fade out t=2.5..3.0
if(reintHintEl){
let op=0;
if(t>=0&&t<=e){
if(t<0.5)op=t/0.5;
else if(t<2.5)op=1;
else if(t<3.0)op=1-(t-2.5)/0.5;
else op=0;
}
reintHintEl.style.opacity=String(op);
}
const lines=document.querySelectorAll('#stun-hints .hint-line');
if(!lines.length)return;
const active=t>=2.5&&t<=e;
const fadeOutStart=e-1.5;
lines.forEach((line,i)=>{
if(!active){line.style.opacity='0';return;}
const fadeInAt=2.5+i*1.0;
if(t=fadeOutStart){
const p=Math.min(1,(t-fadeOutStart)/0.4);
line.style.opacity=String(1-p);
}else{
const p=Math.min(1,(t-fadeInAt)/0.35);
line.style.opacity=String(p);}});}
function updateBossCastBar(t){
const h=currentHeaven(t);
if(!h){
// Show countdown to next Heaven and Hell
let nextStart=Infinity;
for(let i=0;i<3;i++){const s=phase.firstHeaven+i*phase.heavenSpacing;if(s>t){nextStart=s;break;}}
if(nextStart===Infinity){bossCastbarEl.classList.remove("show");return;}
const remaining=Math.max(0,nextStart-t);
bcbLabelEl.textContent=`Heaven & Hell β ${remaining.toFixed(1)}s`;
bcbFillEl.className="bcb-fill casting";
const elapsed=phase.heavenSpacing-(nextStart-(phase.firstHeaven>t?phase.firstHeaven:nextStart-phase.heavenSpacing))-remaining;
bcbFillEl.style.width=`${clamp(1-remaining/phase.heavenSpacing,0,1)*100}%`;
bossCastbarEl.classList.add("show");return;}
const casting=tworld.lightRadius){anyOutside=true;break;}}
if(anyOutside)score-=1.0*dt;
// -5 score if player moves while any splinter is still in-flight
// Window: cycle.start β last splinter's retEnd = start + 2*applyGap + explosionDelay + returnDuration
const sc=activeStarsplinterCycle(t);
const lastRetEnd=sc?sc.start+2*phase.starsApplyGap+phase.starsExplosionDelay+phase.returnDuration:0;
if(sc&&t>=sc.start&&tlightMovePenaltyExpiry){
score-=5;lightMovePenaltyExpiry=t+3;
showMessage('DO NOT MOVE β STARSPLINTERS ACTIVE! β5','danger',2);}
return;}
const scoreRadius=heaven?world.lightRadius:12;
const inLight=len2(sub2(playerPos,stackPos))<=scoreRadius;
if(inLight){
const base=chaoticCheck.checked?1.15:(helperCheck.checked?.5:1);
const gain=base*dt*Number(speedInput.value);
score+=gain;safeAccum+=gain;}}
function gradeScore(s){
if(s>=70)return{g:'A+',cls:'gAp',desc:'Flawless'};
if(s>=55)return{g:'A',cls:'gA',desc:'Excellent'};
if(s>=40)return{g:'B',cls:'gB',desc:'Good'};
if(s>=25)return{g:'C',cls:'gC',desc:'Adequate'};
if(s>=10)return{g:'D',cls:'gD',desc:'Rough'};
if(s>=0) return{g:'F',cls:'gF',desc:'Failed'};
return{g:'F-',cls:'gFm',desc:'Catastrophic'};}
function showScoreboard(){
const rounded=Math.round(score);
const gr=gradeScore(rounded);
const modeLabels={easy:'β EASY MODE',normal:'NORMAL',chaotic:'π₯ CHAOTIC PHASE 4 ATTEMPT'};
const roleSuffix={light:' Β· LIGHT HOLDER',tank:' Β· ADD TANK',dps:''};
const smodeEl=document.getElementById("smode");
smodeEl.textContent=(modeLabels[selectedMode]||'NORMAL')+(roleSuffix[selectedRole]||'');
smodeEl.style.color=selectedMode==='chaotic'?'#ff9f1c':selectedMode==='easy'?'#a8e563':'var(--muted)';
document.getElementById("sgrade").textContent=gr.g;
document.getElementById("sgrade").className=`score-grade ${gr.cls}`;
document.getElementById("sfinal").textContent=`${rounded} pts β ${gr.desc}`;
document.getElementById("sr-safe").textContent=`+${Math.round(safeAccum)}`;
document.getElementById("sr-slice").textContent=`β${penalties.sliceOthers||0}`;
document.getElementById("sr-sliced").textContent=`β${penalties.sliced||0}`;
document.getElementById("sr-munch").textContent=`β${penalties.munched||0}`;
document.getElementById("sr-midnight").textContent=`β${penalties.midnight||0}`;
document.getElementById("sr-zap").textContent=`β${penalties.zapped||0}`;
document.getElementById("sr-wrongside").textContent=`β${penalties.wrongSide||0}`;
document.getElementById("sr-nodef").textContent=`β${penalties.noDefensive||0}`;
document.getElementById("sr-noeab").textContent=`β${penalties.noEabClear||0}`;
scoreboardEl.classList.add("show");scoreboardShown=true;}
// ββ Collision checks ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function checkExplosions(t,stackPos){
const cycle=activeStarsplinterCycle(t);if(!cycle)return;
const playerSP=getCycleAssignment(cycle.index),orientB=getCycleOrientation(cycle.index);
const drops=splinterLayout(stackPos,orientB);
for(let si=0;si<3;si++){
const apply=phase.firstStarsplinter+cycle.index*phase.starsplinterSpacing+si*phase.starsApplyGap;
const explode=apply+phase.starsExplosionDelay,key=`${cycle.index}-${si}`;
const tEff=(playerSplinterForceExplodeAt=apply&&t=explode&&tEff0){
const pen=count*10;score-=pen;penalties.sliceOthers=(penalties.sliceOthers||0)+pen;
showMessage(`YOU SLICED ${count} PLAYER${count>1?'S':''}! β${pen}`,'bad');}
const radial=norm2(stackPos),tangent={x:-radial.y,y:radial.x};
const lateral=(playerPos.x-stackPos.x)*tangent.x+(playerPos.y-stackPos.y)*tangent.y;
const expectedSign=splinterSides[playerSP];
if(Math.abs(lateral)>20&&lateral*expectedSign<0){
score-=15;penalties.wrongSide+=15;
const dirs=['LEFT','RIGHT','LEFT'];
showMessage(`WRONG SIDE β should be ${dirs[playerSP]}! β15`,'bad',3);}
}else{
const angles=shardAnglesForDrop(stackPos,exOrigin,orientB);
for(const ang of angles){if(shardHitsPos(exOrigin,ang,playerPos)){
score-=10;penalties.sliced=(penalties.sliced||0)+10;
showMessage('YOU GOT SLICED! β10','danger');break;}}
if(chaoticCheck.checked){
let killedAny=false;
aiPlayers.forEach((ai,idx)=>{
if(deadAI.has(idx)||!ai.mesh.visible)return;
for(const ang of angles){if(shardHitsPos(exOrigin,ang,ai.currentPos,13)){deadAI.add(idx);ai.mesh.visible=false;killedAny=true;break;}}});
if(killedAny&&time-lastDeathMsgTime>2){
deathMsgEl.textContent=DEATH_REACTIONS[Math.floor(Math.random()*DEATH_REACTIONS.length)];
deathMsgEl.classList.add("show");
deathMsgExpiry=time+2.2;lastDeathMsgTime=time;}
}}}}}
function checkOnYouSP(t,stackPos){
const cycle=activeStarsplinterCycle(t);if(!cycle)return;
const playerSP=getCycleAssignment(cycle.index);
if(playerSP===-1)return;
const apply=phase.firstStarsplinter+cycle.index*phase.starsplinterSpacing+playerSP*phase.starsApplyGap;
const key=`onyou-${cycle.index}`;
if(t>=apply&&th.end){checkedLasers.add(key);return;}
if(len2(sub2(playerPos,beamTip2d))<32){
score-=100;penalties.zapped=(penalties.zapped||0)+100;
showMessage('YOU GOT ZAPPED! β100','danger');checkedLasers.add(key);}}
function checkMunch(t,stackPos){
if(selectedRole==='tank')return;
if(t-lastMunchTime<3)return;
if(len2(sub2(playerPos,stackPos))<18)return;// safe inside stack circle
for(const a of adds){
if(a.wave||t=ap&&tworld.lightRadius){
score-=100;penalties.midnight=(penalties.midnight||0)+100;
showMessage('MIDNIGHT FALLS! β100','danger',2.5);lastMidnightTime=t;}}
// ββ HUD / Raid call βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function updateRaidCall(t){
const countdown=heavenCountdownAt(t);
const heaven=currentHeaven(t);
const chaotic=chaoticCheck.checked;
if(chaotic){
// SP bar hidden in chaos
raidcallEl.classList.remove("show");
// Movement call: flip MOVE/WAIT during countdown, GO GO GO during ramp
if(countdown){
if(t>=nextChaosMoveFlip){
chaosMoveText=CHAOS_MOVE_CALLS[Math.floor(Math.random()*CHAOS_MOVE_CALLS.length)];
nextChaosMoveFlip=t+0.32+Math.random()*0.48;}
movecallEl.querySelector('.movelabel').textContent=chaosMoveText;
movecountdownEl.textContent='';
movecallEl.classList.add("show");
}else if(heaven&&t=cycle.start-1.5){
raidcallEl.classList.add("show");
spSlotEls.forEach((el,i)=>{
const apply=phase.firstStarsplinter+cycle.index*phase.starsplinterSpacing+i*phase.starsApplyGap;
const retEnd=apply+phase.starsExplosionDelay+phase.returnDuration;
el.classList.toggle("stepping",t>=apply&&t<=retEnd);});
}else raidcallEl.classList.remove("show");
}
if(time>messageExpiry)messageEl.className='';}
function updateHud(t){
const stars=activeStarsplinterCycle(t),heaven=currentHeaven(t);
let label="Holding";
if(t<=phase.reintegrationCast)label="Reintegration";
else if(t<=phase.reintegrationCast+phase.stunDuration)label="Stunned";
else if(stars&&t>=stars.start&&t<=stars.end)label="Starsplinters";
else if(heaven)label="Heaven and Hell";
clockEl.textContent=`${t.toFixed(1)}s`;stateEl.textContent=label;
cycleEl.textContent=`${Math.min(4,Math.max(1,stars?stars.index+1:Math.floor((t-phase.firstStarsplinter)/phase.starsplinterSpacing)+1))} / 4`;
scorePillEl.textContent=Math.round(score);}
// ββ Frame βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function frame(now){
const dt=Math.min(.08,(now-last)/1000);last=now;
if(running){
time+=dt*Number(speedInput.value);
if(time>=phase.duration){
time=phase.duration;running=false;playBtn.textContent="Play";
if(!scoreboardShown)showScoreboard();}}
const stackPos=raidPosAt(time),otPos=offTankPos(stackPos);
const immunePosNow=immunityPos(time,stackPos);
const liftY=stunLiftAt(time);
const dur=time>=phase.reintegrationCast&&time<=phase.reintegrationCast+phase.stunDuration;
const aft=time>phase.reintegrationCast+phase.stunDuration;
const showRaid=dur||aft;
updateAdds(time,dt*Number(speedInput.value),stackPos,otPos,immunePosNow);
updateDyingAdds(time);
if(dur)playerPos={...stackPos};
stackGlow.visible=lightRing.visible=lightRingGlow.visible=lightDisc.visible=lightDiscRed.visible=showRaid;
stackGlow.position.copy(w2v(stackPos,.05+liftY));
const lightCenter=selectedRole==='light'?playerPos:stackPos;
lightRing.position.copy(w2v(lightCenter,.06+liftY));
lightRingGlow.position.copy(w2v(lightCenter,.06+liftY));
lightDisc.position.set(lightCenter.x*scale,.06+liftY,lightCenter.y*scale);
lightDiscRed.position.set(lightCenter.x*scale,.065+liftY,lightCenter.y*scale);
otCyl.visible=showRaid&&selectedRole!=='tank';otCyl.position.copy(w2v(otPos,.36+liftY));
immuneMesh.visible=Boolean(aft&Β€tHeaven(time));
immuneMesh.position.copy(w2v(immunePosNow,.34));
const heaven=currentHeaven(time);
if(heaven)immuneMesh.material.color.setHex(immunityColors[heaven.index]);
updateStun(time);
updateBeam(time);
updateSplinters(time,stackPos);
updateAIPlayers(time,stackPos,liftY);
updatePlayer(time,dt*Number(speedInput.value),stackPos,liftY);
updateOTCone(time,stackPos);
updateConeKills(time,stackPos);
updateWorldMarkers(time);
updateStunHints(time);
updateBossCastBar(time);
updateCastBar(time);
updateCamera(liftY);
// reintHintEl opacity managed by updateStunHints
if(scorephase.firstStarsplinter+sc.index*phase.starsplinterSpacing+getCycleAssignment(sc.index)*phase.starsApplyGap+phase.starsExplosionDelay+phase.returnDuration)
playerSplinterForceExplodeAt=Infinity;}
checkOnYouSP(time,stackPos);
checkZap(time);
checkMunch(time,stackPos);
// Add Tank: penalise β75 if EAB not used to clear red wave
if(selectedRole==='tank'){
const wc=activeStarsplinterCycle(time);
if(wc&&waveSpawned.has(wc.index)&&!checkedWavePenalties.has(wc.index)&&time>=wc.start+3.0){
checkedWavePenalties.add(wc.index);
if(!tankEabWaveKillCycles.has(wc.index)){
score-=75;penalties.noEabClear=(penalties.noEabClear||0)+75;
showMessage('ADDS HIT THE RAID! β75','danger',3);}}}
checkMidnight(time,stackPos);}
// ββ Chaotic mode overlays ββββββββββββββββββββββββββββββββββββββββββββββββ
if(chaoticCheck.checked&&running){
// World cup
if(time>=nextWorldCupTime){
const c=CHAOTIC_CORNERS[Math.floor(Math.random()*4)];
Object.assign(worldCupEl.style,{top:c.top,left:c.left,right:c.right,bottom:c.bottom});
worldCupEl.textContent=WCGOALS[Math.floor(Math.random()*WCGOALS.length)];
worldCupEl.classList.add("show");
worldCupExpiry=time+3;nextWorldCupTime=time+12+Math.random()*14;}
if(time>worldCupExpiry)worldCupEl.classList.remove("show");
// Guild drama
if(time>=nextGuildDrama&&!gcDramaActive){
const leaver=GUILD_NAMES[Math.floor(Math.random()*GUILD_NAMES.length)];
const others=[...GUILD_NAMES].filter(n=>n!==leaver).sort(()=>Math.random()-.5);
const resps=[...GUILD_RESPONSES].sort(()=>Math.random()-.5);
const count=4+Math.floor(Math.random()*5);
gcQueue=[{name:leaver,text:'has left the guild.',type:'leave',delay:0,added:false}];
for(let i=0;i{
if(!m.added&&el>=m.delay){
m.added=true;
const d=document.createElement('div');d.className='gc-msg';
d.innerHTML=m.type==='leave'
?`${m.name} ${m.text}`
:m.type==='offline'
?`${m.name} ${m.text}`
:`${m.name}: ${m.text}`;
gcMsgsEl.appendChild(d);}});
const last=gcQueue[gcQueue.length-1];
if(last.added&&time-gcDramaStart>last.delay+5){guildChatEl.classList.remove("show");gcDramaActive=false;}
}
// Discord notifications
if(time>=nextDcTime){
dcSenderEl.textContent=DISCORD_NAMES[Math.floor(Math.random()*DISCORD_NAMES.length)];
dcTextEl.textContent=DISCORD_MSGS[Math.floor(Math.random()*DISCORD_MSGS.length)];
discordEl.classList.add("show");
dcExpiry=time+5;nextDcTime=time+18+Math.random()*20;}
if(time>dcExpiry)discordEl.classList.remove("show");
// LUA error popup
if(time>=nextLuaErrorTime&&!luaErrorShowing){
luaErrorBodyEl.textContent=LUA_ERRORS[Math.floor(Math.random()*LUA_ERRORS.length)];
luaErrorEl.classList.add("show");luaErrorShowing=true;
nextLuaErrorTime=time+25+Math.random()*22;}
// Gone offline guild event
if(time>=nextOfflineTime&&!gcDramaActive){
const name=GUILD_NAMES[Math.floor(Math.random()*GUILD_NAMES.length)];
const resps=[...OFFLINE_RESPONSES].sort(()=>Math.random()-.5).slice(0,1+Math.floor(Math.random()*2));
const others=[...GUILD_NAMES].filter(n=>n!==name).sort(()=>Math.random()-.5);
gcQueue=[{name,text:'has gone offline.',type:'offline',delay:0,added:false}];
resps.forEach((r,i)=>gcQueue.push({name:others[i],text:guildResp(others[i],r),type:'resp',delay:(i+1)*.9+Math.random()*.4,added:false}));
gcDramaStart=time;gcDramaActive=true;gcMsgsEl.innerHTML='';guildChatEl.classList.add("show");
nextOfflineTime=time+28+Math.random()*22;}
// Death message hide
if(time>deathMsgExpiry)deathMsgEl.classList.remove("show");
// Easter egg lines
for(const s of easterEggSchedule){
if(!s.fired&&time>=s.t){
s.fired=true;
addGuildChatMsg(s.egg.name,s.egg.text);
if(s.egg.reply)pendingGcReplies.push({fireAt:time+1.2+Math.random()*.8,name:s.egg.reply.name,text:s.egg.reply.text});
gcEggExpiry=time+7;guildChatEl.classList.add("show");}}
for(let i=pendingGcReplies.length-1;i>=0;i--){
const r=pendingGcReplies[i];
if(time>=r.fireAt){addGuildChatMsg(r.name,r.text);gcEggExpiry=time+6;pendingGcReplies.splice(i,1);}}
if(!gcDramaActive&&time>gcEggExpiry)guildChatEl.classList.remove("show");
}else{
worldCupEl.classList.remove("show");
guildChatEl.classList.remove("show");
discordEl.classList.remove("show");
gcDramaActive=false;}
boss.rotation.y+=dt*.35;
renderer.render(scene,camera);
requestAnimationFrame(frame);}
function doRestart(){
for(const a of adds)scene.remove(a.mesh);
for(const d of dyingAdds)scene.remove(d.mesh);
adds=[];dyingAdds=[];time=0;seed=5;lastAddTime=-999;nextAddId=1;waveSpawned=new Set();
Object.keys(cycleAssignments).forEach(k=>delete cycleAssignments[k]);
Object.keys(cycleOrientations).forEach(k=>delete cycleOrientations[k]);
Object.keys(cycleDelays).forEach(k=>delete cycleDelays[k]);
Object.keys(cycleAdjustedDrops).forEach(k=>delete cycleAdjustedDrops[k]);
Object.keys(cycleFakeouts).forEach(k=>delete cycleFakeouts[k]);
Object.keys(cycleSafeDrops).forEach(k=>delete cycleSafeDrops[k]);
aiWander.forEach(w=>w.active=false);nextWanderTime=20+Math.random()*12;
checkedExplosions.clear();checkedLasers.clear();firedConeKills.clear();
tankEabWaveKillCycles.clear();checkedWavePenalties.clear();
playerPos={x:0,y:-world.stackDistance};playerFacingDir={x:0,y:1};
messageExpiry=0;messageEl.className='';lastMunchTime=-999;lastMidnightTime=-999;
score=0;safeAccum=0;Object.keys(penalties).forEach(k=>penalties[k]=0);scoreboardShown=false;
deadAI.clear();aiPlayers.forEach(ai=>ai.mesh.visible=true);
worldCupExpiry=-999;nextWorldCupTime=18+Math.random()*8;worldCupEl.classList.remove("show");
gcQueue=[];gcDramaActive=false;gcMsgsEl.innerHTML='';guildChatEl.classList.remove("show");nextGuildDrama=22+Math.random()*8;
dcExpiry=-999;nextDcTime=35+Math.random()*10;discordEl.classList.remove("show");
chaosMoveText='WAIT!!';nextChaosMoveFlip=-999;
luaErrorEl.classList.remove("show");luaErrorShowing=false;nextLuaErrorTime=28+Math.random()*12;
deathMsgEl.classList.remove("show");deathMsgExpiry=-999;lastDeathMsgTime=-999;
nextOfflineTime=45+Math.random()*10;
gcEggExpiry=-999;pendingGcReplies=[];buildEasterEggSchedule();buildStunHints();
scoreboardEl.classList.remove("show");
reintHintEl.style.opacity='0';
prevScore=0;penaltyFlashExpiry=-999;spFlashExpiry=-999;penaltyFlashEl.classList.remove("on");spFlashEl.classList.remove("on");
eabCooldownUntil=-Infinity;eabConePos=null;eabConeExpiry=-Infinity;eabConeFired=false;
defCharges=3;defExpiry=-Infinity;lightMovePenaltyExpiry=-Infinity;
document.getElementById('eab-icon').classList.remove('show');
document.getElementById('def-charges').classList.remove('show');
document.getElementById('def-bar-wrap').classList.remove('show');
document.getElementById('role-step').style.display='flex';
document.getElementById('diff-step').style.display='none';
document.getElementById('eab-bind-row').style.display='none';
document.getElementById('tank-continue-btn').style.display='none';
modeSelectEl.style.display='flex';
running=false;playBtn.textContent="Pause";
camera.position.set(0,Number(camHeightInput.value),-Number(camDistInput.value)*scale);last=performance.now();}
const STUN_HINTS={
dps:[
'Remain pixel-stacked with the group whenever possible.',
'Move to the correct side with Starsplinter. (ZQSD)',
'Don\'t get hit by Void Swarm adds.',
'Don\'t fall behind the raid during Heaven & Hell.'
],
light:[
'Stand still on top of the raid during Starsplinters.',
'Move during Heaven and Hell without leaving people behind. (ZQSD)',
'Don\'t get hit by Void Swarm adds.'
],
tank:[
'Remain slightly in front of the group at all times.',
'Use \'The Last Light\' action to clear adds in front of you.',
'Use a defensive to soak any adds that will hit the group.',
'Move during Heaven & Hell and help to soak adds.'
],
chaotic:[
'You have selected Chaos Mode. Good Luck.',
'Your raid is arguing about whether someone was in the wrong spot in P3.',
'Your Raid Leader tells you to calm down.',
'Your raid leader tells you that this is the easiest part of the fight.',
'Rage consumes you as someone disconnects.',
'Your UI has been throwing errors all pull.'
]
};
function buildStunHints(){
const lines=chaoticCheck.checked?STUN_HINTS.chaotic:(STUN_HINTS[selectedRole]||STUN_HINTS.dps);
const el=document.getElementById('stun-hints');
el.innerHTML=lines.map(l=>`${l}
`).join('');}
function startMode(mode){
selectedMode=mode;
helperCheck.checked=(mode==='easy');
chaoticCheck.checked=(mode==='chaotic');
buildEasterEggSchedule();
buildStunHints();
modeSelectEl.style.display='none';
if(selectedRole==='tank'){
document.getElementById('eab-icon').classList.add('show');
document.getElementById('def-charges').classList.add('show');
defCharges=3;defExpiry=-Infinity;updateDefUI();}
running=true;playBtn.textContent="Pause";last=performance.now();}
// Defensive UI helper
function updateDefUI(){
const pips=document.getElementById('def-pips');
pips.innerHTML='';
for(let i=0;i<3;i++){const d=document.createElement('div');d.className='def-pip'+(i>=defCharges?' spent':'');pips.appendChild(d);}}
// Role step β diff step
const roleLabels={'dps':'DPS / Healer','light':'Light Holder','tank':'Add Tank'};
function selectRole(role){
selectedRole=role;
document.getElementById('diff-role-label').textContent=roleLabels[role]+' Β· Choose difficulty';
document.getElementById('role-step').style.display='none';
document.getElementById('diff-step').style.display='flex';}
document.getElementById("btn-role-dps").addEventListener("click",()=>selectRole('dps'));
document.getElementById("btn-role-light").addEventListener("click",()=>selectRole('light'));
document.getElementById("btn-role-tank").addEventListener("click",()=>{
document.getElementById('eab-bind-row').style.display='flex';
document.getElementById('tank-continue-btn').style.display='block';
selectedRole='tank';});
document.getElementById("tank-continue-btn").addEventListener("click",()=>selectRole('tank'));
document.getElementById("btn-back-role").addEventListener("click",()=>{
document.getElementById('diff-step').style.display='none';
document.getElementById('role-step').style.display='flex';});
// EAB / Defensive keybind capture
const eabKeyBtn=document.getElementById('eab-key-btn');
const defKeyBtn=document.getElementById('def-key-btn');
let capturingEab=false,capturingDef=false;
eabKeyBtn.addEventListener("click",()=>{capturingEab=true;capturingDef=false;eabKeyBtn.textContent='β¦';eabKeyBtn.classList.add('capturing');});
defKeyBtn.addEventListener("click",()=>{capturingDef=true;capturingEab=false;defKeyBtn.textContent='β¦';defKeyBtn.classList.add('capturing');});
window.addEventListener("keydown",e=>{
if(capturingEab){e.preventDefault();eabKey=e.key.toLowerCase();
eabKeyBtn.textContent=e.key.toUpperCase();eabKeyBtn.classList.remove('capturing');capturingEab=false;return;}
if(capturingDef){e.preventDefault();defKey=e.key.toLowerCase();
defKeyBtn.textContent=e.key.toUpperCase();document.getElementById('def-key-label').textContent=e.key.toUpperCase();
defKeyBtn.classList.remove('capturing');capturingDef=false;return;}},true);
document.getElementById("btn-easy").addEventListener("click",()=>startMode('easy'));
document.getElementById("btn-normal").addEventListener("click",()=>startMode('normal'));
document.getElementById("btn-chaotic").addEventListener("click",()=>startMode('chaotic'));
playBtn.addEventListener("click",()=>{running=!running;playBtn.textContent=running?"Pause":"Play";last=performance.now();});
restartBtn.addEventListener("click",doRestart);
document.getElementById("sbRestart").addEventListener("click",doRestart);
window.addEventListener("resize",()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});
camera.position.set(0,CAM_H,-CAM_BACK*scale);
requestAnimationFrame(frame);
</script>