<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>RhythmStudio — Full Editor & Play</title>
<style>
:root{--bg:#061528;--panel:#0d2033;--accent:#6ad1ff;--text:#eaf6ff;--good:#7ef29a;--warn:#ffb265;--bad:#ff6f6f;}
*{box-sizing:border-box}
body{margin:0;font-family:Inter, "Segoe UI", Roboto, Arial, sans-serif;background:linear-gradient(180deg,var(--bg),#02131b);color:var(--text)}
.header{display:flex;align-items:center;padding:10px 14px;gap:12px;border-bottom:1px solid rgba(255,255,255,0.04)}
.container{display:grid;grid-template-columns:420px 1fr;gap:12px;padding:12px}
.card{background:linear-gradient(180deg,var(--panel),#071b2b);padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03)}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.small{font-size:13px;color:#9fb8d9}
.btn{background:#0e2b3f;border:1px solid #224d66;padding:6px 10px;border-radius:8px;color:var(--text);cursor:pointer}
.btn.primary{background:linear-gradient(180deg,#1a6ba1,#0f4b75)}
.input{background:#071a2b;border:1px solid #12324a;padding:6px;border-radius:6px;color:var(--text)}
canvas{width:100%;height:560px;border-radius:8px;background:linear-gradient(180deg,#021322,#051528);display:block}
.timeline{height:120px;background:#061726;border-radius:8px;margin-top:10px;position:relative;overflow:hidden}
.note-chip{position:absolute;padding:4px 6px;border-radius:6px;background:#12314f;color:#d7eaff;font-size:12px;border:1px solid rgba(255,255,255,0.03);cursor:pointer}
.inspector{margin-top:12px;padding:10px;border-radius:8px;background:#061a2b;border:1px solid rgba(255,255,255,0.03)}
.progressOuter{height:12px;background:#041826;border-radius:8px;border:1px solid #123;overflow:hidden}
.progressInner{height:100%;background:linear-gradient(90deg,#7be0ff,#7effb3);width:0%}
.bigJudge{position:absolute;left:50%;transform:translateX(-50%);font-weight:900;pointer-events:none}
.mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace}
.hidden{display:none}
.pill{background:rgba(255,255,255,0.02);padding:6px 8px;border-radius:8px;font-size:13px}
</style>
</head>
<body>
<div class="header">
<div style="font-weight:800">RhythmStudio — Full Editor & Play</div>
<div style="flex:1"></div>
<div class="small">Mode</div>
<select id="modeSelect" class="input"><option value="editor">Editor / Test</option><option value="play">Play</option></select>
<div style="width:12px"></div>
<div class="pill">Score <span id="score" class="mono">0</span></div>
<div style="width:8px"></div>
<div class="pill">Multiplier <span id="mult" class="mono">1</span></div>
<div style="width:8px"></div>
<div class="pill">Charge <div style="display:inline-block;width:140px;margin-left:8px"><div class="progressOuter"><div id="chargeFill" class="progressInner" style="width:0%"></div></div></div></div>
<div style="width:8px"></div>
<div class="pill">Last <span id="lastJudge" class="mono">-</span></div>
<div id="playerOffsetWrap" style="margin-left:12px;display:none;align-items:center" class="row">
<label class="small">Player Offset (ms)</label>
<input id="playerOffset" type="number" value="0" class="input" style="width:90px">
</div>
</div>
<div class="container">
<aside class="card">
<div style="font-weight:700">Editor / Tools</div>
<div class="row"><label class="small">Upload Music</label><input id="musicFile" type="file" accept="audio/*" class="input"></div>
<div class="row"><label class="small">Import Chart (JSON)</label><input id="chartFile" type="file" accept="application/json" class="input"></div>
<div class="row"><button id="exportBtn" class="btn">Export JSON</button><button id="resetBtn" class="btn">Reset</button></div>
<hr style="border-color:rgba(255,255,255,0.03)">
<div class="small">Lanes</div>
<div class="row"><label class="small">Count</label><input id="laneCount" type="number" min="1" max="12" value="4" class="input" style="width:80px"></div>
<div id="laneConfig"></div>
<div class="row"><label class="small">Add notes to lane</label>
<select id="currentAddLane" class="input"></select>
</div>
<hr style="border-color:rgba(255,255,255,0.03)">
<div class="small">Beat grid settings (for '*' time input)</div>
<div class="row"><label class="small">First beat offset (ms)</label><input id="firstBeatMs" class="input" type="number" value="0" style="width:120px"></div>
<div class="row"><label class="small">Beats per minute (BPM)</label><input id="bpm" class="input" type="number" value="120" style="width:120px"></div>
<hr style="border-color:rgba(255,255,255,0.03)">
<div class="small">Add Note (at current time)</div>
<div class="row" id="addBtns">
<button id="addTap" class="btn">Tap</button>
<button id="addHold" class="btn">Hold</button>
<button id="addChord" class="btn">Chord</button>
<button id="addSeq" class="btn">Seq</button>
<button id="addFunc" class="btn">Func</button>
</div>
<hr style="border-color:rgba(255,255,255,0.03)">
<div class="small">Judgement windows (ms)</div>
<div class="row"><label class="small">wonderful</label><input id="j_w" type="number" value="20" class="input" style="width:80px"></div>
<div class="row"><label class="small">perfect+</label><input id="j_pp" type="number" value="40" class="input" style="width:80px"></div>
<div class="row"><label class="small">perfect</label><input id="j_p" type="number" value="80" class="input" style="width:80px"></div>
<div class="row"><label class="small">good</label><input id="j_g" type="number" value="140" class="input" style="width:80px"></div>
<div class="row"><label class="small">miss late</label><input id="j_late" type="number" value="400" class="input" style="width:80px"></div>
<hr style="border-color:rgba(255,255,255,0.03)">
<div class="small">Inspector</div>
<div class="inspector" id="inspector">
<div id="noIns" class="small">Click note in canvas or timeline to edit</div>
<div id="insForm" class="hidden">
<div class="row"><label class="small">Type</label><select id="ins_type" class="input"><option>tap</option><option>hold</option><option>chord</option><option>seq</option><option>func</option></select></div>
<div class="row"><label class="small">Lane</label><input id="ins_lane" type="number" class="input" style="width:80px"></div>
<div class="row"><label class="small">Time (ms or *beat)</label><input id="ins_time" type="text" class="input" style="width:180px"></div>
<div class="row" id="durRow"><label class="small">Duration ms</label><input id="ins_d" type="number" class="input" style="width:140px"></div>
<div class="row"><label class="small">Keys (A+B)</label><input id="ins_keys" class="input" placeholder="A+B"></div>
<div class="row"><label class="small">Thickness</label><input id="ins_thick" type="number" class="input" value="18" style="width:80px"></div>
<div class="row"><label class="small">Color</label><input id="ins_color" type="color" class="input" value="#6ad1ff"></div>
<div class="row"><label class="small">Func JSON (keys & speed)</label><input id="ins_func" class="input" placeholder='{"keys":["A"],"speed":800}'></div>
<div class="row"><button id="ins_apply" class="btn">Apply</button><button id="ins_del" class="btn">Delete</button></div>
</div>
</div>
</aside>
<main class="card">
<div style="display:flex;gap:12px;align-items:center;margin-bottom:8px">
<div class="small">Rate</div>
<input id="rate" type="range" min="0.3" max="3" step="0.1" value="1">
<div id="rateVal" class="pill mono">1.0x</div>
<div style="flex:1"></div>
<div class="pill">Last Result <span id="hudLast" class="mono">-</span></div>
</div>
<canvas id="stage" width="1200" height="560"></canvas>
<div style="height:8px"></div>
<div class="row">
<input id="seek" type="range" min="0" max="0" step="0.001" style="flex:1">
<div class="pill mono" id="timeDisplay">00:00.000</div>
<button id="start" class="btn primary">Start</button>
<button id="pause" class="btn">Pause</button>
<button id="stop" class="btn">Stop</button>
</div>
<div id="timeline" class="timeline"></div>
</main>
</div>
<audio id="audio" crossorigin="anonymous"></audio>
<script>
/* RhythmStudio — Full single-file
- preserves editor & play features
- keeps lane-moving fix
- shows keys on notes
- default note thickness 18
- star-time (*beat) is stored as string and evaluated dynamically
- Play mode player offset (ms) affects judgement only
- Backspace/Delete not bound to delete
*/
// Basic setup
const audio = document.getElementById('audio');
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const DPR = window.devicePixelRatio || 1;
function resize(){ const r = canvas.getBoundingClientRect(); canvas.width = Math.floor(r.width * DPR); canvas.height = Math.floor(r.height * DPR); ctx.setTransform(DPR,0,0,DPR,0,0); }
window.addEventListener('resize', resize);
resize();
// DOM
const musicFile = document.getElementById('musicFile'), chartFile = document.getElementById('chartFile'), exportBtn = document.getElementById('exportBtn'), resetBtn = document.getElementById('resetBtn');
const laneCount = document.getElementById('laneCount'), laneConfig = document.getElementById('laneConfig'), currentAddLane = document.getElementById('currentAddLane');
const firstBeatMsInp = document.getElementById('firstBeatMs'), bpmInp = document.getElementById('bpm');
const addTap = document.getElementById('addTap'), addHold = document.getElementById('addHold'), addChord = document.getElementById('addChord'), addSeq = document.getElementById('addSeq'), addFunc = document.getElementById('addFunc');
const j_w = document.getElementById('j_w'), j_pp = document.getElementById('j_pp'), j_p = document.getElementById('j_p'), j_g = document.getElementById('j_g'), j_late = document.getElementById('j_late');
const insForm = document.getElementById('insForm'), noIns = document.getElementById('noIns');
const ins_type = document.getElementById('ins_type'), ins_lane = document.getElementById('ins_lane'), ins_time = document.getElementById('ins_time'), ins_d = document.getElementById('ins_d'), ins_keys = document.getElementById('ins_keys'), ins_thick = document.getElementById('ins_thick'), ins_color = document.getElementById('ins_color'), ins_func = document.getElementById('ins_func'), ins_apply = document.getElementById('ins_apply'), ins_del = document.getElementById('ins_del');
const rate = document.getElementById('rate'), rateVal = document.getElementById('rateVal');
const seek = document.getElementById('seek'), timeDisplay = document.getElementById('timeDisplay'), startBtn = document.getElementById('start'), pauseBtn = document.getElementById('pause'), stopBtn = document.getElementById('stop'), timeline = document.getElementById('timeline');
const scoreEl = document.getElementById('score'), multEl = document.getElementById('mult'), chargeFill = document.getElementById('chargeFill'), lastJudge = document.getElementById('lastJudge'), hudLast = document.getElementById('hudLast');
const modeSelect = document.getElementById('modeSelect');
const playerOffsetWrap = document.getElementById('playerOffsetWrap'), playerOffsetInput = document.getElementById('playerOffset');
// model
let chart = { meta:{ defaultKeys:['A','B'], defaultSpeed:600 }, lanes:[] };
let nextId = 1;
// runtime
let pressed = new Set();
let lastPress = {};
let score = 0, multiplier = 1, charge = 0;
const CHARGE_THRESHOLD = 30;
let mode = 'editor';
let playbackRate = 1;
let selectedNote = null;
let playerOffsetMs = 0;
// ensure initial lanes
function ensureLanes(n){
while(chart.lanes.length < n) chart.lanes.push({ baseKeys: chart.meta.defaultKeys.slice(), baseSpeed: chart.meta.defaultSpeed || 600, notes: [] });
while(chart.lanes.length > n) chart.lanes.pop();
rebuildLaneUI();
rebuildCurrentLaneOptions();
}
ensureLanes(+laneCount.value);
// UI: lanes
function rebuildLaneUI(){
laneConfig.innerHTML = '';
chart.lanes.forEach((lane,i)=>{
const row = document.createElement('div'); row.className='row';
const lab = document.createElement('div'); lab.className='small'; lab.textContent = `Lane ${i+1}`;
const keys = document.createElement('input'); keys.className='input'; keys.value = (lane.baseKeys||[]).join('+');
keys.addEventListener('change', ()=> lane.baseKeys = keys.value.split(/\s*\+\s*/).map(s=>s.trim().toUpperCase()).filter(Boolean));
const speed = document.createElement('input'); speed.className='input'; speed.type='number'; speed.value = lane.baseSpeed || chart.meta.defaultSpeed; speed.style.width='96px';
speed.addEventListener('change', ()=> lane.baseSpeed = +speed.value || chart.meta.defaultSpeed);
row.appendChild(lab); row.appendChild(keys); row.appendChild(speed);
laneConfig.appendChild(row);
});
}
function rebuildCurrentLaneOptions(){
currentAddLane.innerHTML = '';
for(let i=0;i<chart.lanes.length;i++){
const opt = document.createElement('option'); opt.value = i; opt.textContent = `Lane ${i+1}`; currentAddLane.appendChild(opt);
}
}
// parseTime: supports '*' beat-syntax dynamic evaluation
function parseTimeToSec(raw){
if(raw === undefined || raw === null) return 0;
if(typeof raw === 'number') return raw;
raw = String(raw).trim();
if(raw.startsWith('*')){
// syntax: *beat|denominator|...
const rawBody = raw.slice(1);
const parts = rawBody.split('|').map(s=>s.trim()).filter(Boolean);
const beatIndex = parseFloat(parts[0]) || 0;
let frac = 0;
for(let i=1;i<parts.length;i++){
const den = parseFloat(parts[i]) || 0;
if(den>0) frac += 1/den;
}
const firstMs = +(firstBeatMsInp.value||0);
const bpm = +(bpmInp.value||120);
const beatMs = 60000 / Math.max(1, bpm);
const totalMs = firstMs + ((beatIndex - 1) + frac) * beatMs;
return totalMs / 1000.0;
}
// support mm:ss.xxx, seconds, or ms (if >1000 assume ms)
if(/^\d+:\d+\.\d+$/.test(raw)){ const [mm,rest]=raw.split(':'); return parseInt(mm)*60 + parseFloat(rest); }
if(/^\d+:\d+$/.test(raw)){ const [mm,ss]=raw.split(':'); return parseInt(mm)*60 + parseInt(ss); }
if(/^\d+$/.test(raw) && raw.length>4) return parseInt(raw)/1000.0;
const v = parseFloat(raw);
return isFinite(v) ? v : 0;
}
// add notes (default thickness = 18)
function addNote(type){
ensureLanes(+laneCount.value);
const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, +currentAddLane.value || 0));
const t = audio.currentTime || 0;
const n = { id: nextId++, type, lane: laneIdx, t: t, d: (type==='hold'?1000:0), keys: null, thickness: 18, color:'#6ad1ff', func:null, _judged:false, _holding:false, holdProgress:0, origTimeInput: null };
chart.lanes[laneIdx].notes.push(n);
chart.lanes[laneIdx].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
resetRuntimeFrom(parseTimeToSec(t) - 0.05);
rebuildTimeline(); draw();
}
addTap.addEventListener('click', ()=> addNote('tap'));
addHold.addEventListener('click', ()=> addNote('hold'));
addChord.addEventListener('click', ()=> addNote('chord'));
addSeq.addEventListener('click', ()=> addNote('seq'));
addFunc.addEventListener('click', ()=> {
const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, +currentAddLane.value || 0));
const t = audio.currentTime || 0;
const func = { keys: chart.lanes[laneIdx].baseKeys.slice(), speed: chart.lanes[laneIdx].baseSpeed || chart.meta.defaultSpeed };
const n = { id: nextId++, type:'func', lane: laneIdx, t: t, d:0, func, color:'#ffd36b' };
chart.lanes[laneIdx].notes.push(n);
chart.lanes[laneIdx].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
resetRuntimeFrom(parseTimeToSec(t) - 0.05);
rebuildTimeline(); draw();
});
// import/export
musicFile.addEventListener('change', e=>{
const f = e.target.files[0]; if(!f) return;
if(audio.src) try{ URL.revokeObjectURL(audio.src); }catch(e){}
audio.src = URL.createObjectURL(f); audio.load();
audio.addEventListener('loadedmetadata', ()=>{ seek.max = Math.max(audio.duration||0, getChartEnd()); rebuildTimeline(); }, { once:true });
});
chartFile.addEventListener('change', async e=>{
const f = e.target.files[0]; if(!f) return;
const txt = await f.text();
try{
const obj = JSON.parse(txt);
chart = obj;
chart.lanes.forEach(l=> l.notes = l.notes || []);
nextId = Math.max(nextId, ...chart.lanes.flatMap(l=>l.notes.map(n=>n.id||0))) + 1;
ensureLanes(chart.lanes.length);
resetRuntimeAll();
rebuildTimeline(); draw();
}catch(err){ alert('Invalid JSON'); }
});
exportBtn.addEventListener('click', ()=>{ const blob = new Blob([JSON.stringify(chart,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href = URL.createObjectURL(blob); a.download='chart.json'; a.click(); });
resetBtn.addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; resetRuntimeAll(); rebuildTimeline(); draw(); });
// timeline rendering
function getChartEnd(){ let m=0; for(const l of chart.lanes) for(const n of l.notes) m = Math.max(m, parseTimeToSec(n.t) + (n.d? n.d/1000:0)); return m; }
function rebuildTimeline(){
timeline.innerHTML = '';
const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
for(let s=0;s<dur;s++){
const v = document.createElement('div'); v.style.position='absolute'; v.style.left=(s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background=(s%5===0)?'rgba(255,255,255,0.03)':'rgba(255,255,255,0.01)';
timeline.appendChild(v);
}
chart.lanes.forEach((lane,i)=>{
lane.notes.forEach(n=>{
const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = n.id;
chip.style.top = (6 + i*22) + 'px';
chip.style.left = ((parseTimeToSec(n.t)/dur*100) + '%');
chip.textContent = n.type === 'tap' ? (n.keys? n.keys.join('+') : 'Tap') : (n.type==='hold'?'Hold':n.type.toUpperCase());
chip.style.background = n.color || '#6ad1ff';
chip.addEventListener('click', ev=>{ ev.stopPropagation(); selectNoteById(n.id); showInspector(n); });
timeline.appendChild(chip);
});
});
const cur = document.createElement('div'); cur.id='miniCursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff';
timeline.appendChild(cur);
updateMiniCursor();
}
function updateMiniCursor(){ const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0))); const cur = document.getElementById('miniCursor'); if(cur) cur.style.left = ((audio.currentTime||0)/dur*100) + '%'; }
// inspector (select/move-lane fix)
function selectNoteById(id){
for(const lane of chart.lanes){
const n = lane.notes.find(x=>x.id===id);
if(n){ selectedNote = n; showInspector(n); return; }
}
selectedNote = null; hideInspector();
}
function showInspector(n){
noIns.style.display='none'; insForm.classList.remove('hidden');
ins_type.value = n.type; ins_lane.value = n.lane || 0; ins_time.value = (typeof n.t === 'string' ? n.t : Math.round(parseTimeToSec(n.t)*1000)); ins_d.value = n.d || 0;
ins_keys.value = n.keys ? n.keys.join('+') : ''; ins_thick.value = n.thickness || 18; ins_color.value = n.color || '#6ad1ff'; ins_func.value = n.func ? JSON.stringify(n.func) : '';
}
function hideInspector(){ noIns.style.display='block'; insForm.classList.add('hidden'); selectedNote = null; }
// apply inspector edits, move note if lane changed
ins_apply.addEventListener('click', ()=>{
if(!selectedNote) return;
const oldLane = selectedNote.lane || 0;
const newLane = Math.max(0, Math.min(chart.lanes.length-1, +ins_lane.value || 0));
// time input accept '*' or ms or seconds; we store raw input as string if contains '*'
const timeInputStr = (ins_time.value || '').toString().trim();
selectedNote.t = timeInputStr.startsWith('*') ? timeInputStr : (timeInputStr === '' ? selectedNote.t : (parseFloat(timeInputStr) > 10000 ? (parseFloat(timeInputStr)/1000) : parseFloat(timeInputStr)));
selectedNote.d = (+ins_d.value || 0);
const kstr = ins_keys.value.trim(); selectedNote.keys = kstr ? kstr.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null;
selectedNote.thickness = +ins_thick.value || 18;
selectedNote.color = ins_color.value || '#6ad1ff';
try{ selectedNote.func = selectedNote.type==='func' && ins_func.value.trim() ? JSON.parse(ins_func.value) : null; } catch(e){ alert('Invalid func JSON'); return; }
if(newLane !== oldLane){
const idx = (chart.lanes[oldLane].notes || []).findIndex(n => n.id === selectedNote.id);
if(idx !== -1) chart.lanes[oldLane].notes.splice(idx, 1);
selectedNote.lane = newLane;
chart.lanes[newLane].notes.push(selectedNote);
chart.lanes[newLane].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
} else {
chart.lanes[oldLane].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
}
resetRuntimeFrom(parseTimeToSec(selectedNote.t) - 0.05);
rebuildTimeline(); draw();
});
// delete via inspector only
ins_del.addEventListener('click', ()=>{
if(!selectedNote) return;
if(!confirm('Delete note?')) return;
for(const lane of chart.lanes){
const i = lane.notes.findIndex(n => n.id === selectedNote.id);
if(i >= 0){ lane.notes.splice(i,1); selectedNote = null; hideInspector(); rebuildTimeline(); draw(); return; }
}
});
// playback controls & UI
rate.addEventListener('input', ()=>{ playbackRate = +rate.value; rateVal.textContent = playbackRate.toFixed(1) + 'x'; audio.playbackRate = playbackRate; });
startBtn.addEventListener('click', async ()=>{
if(!audio.src){ alert('Please upload music'); return; }
mode = modeSelect.value;
if(mode === 'play'){ document.getElementById('addBtns').style.display='none'; laneConfig.style.display='none'; seek.disabled = true; playerOffsetWrap.style.display = 'flex'; }
else { document.getElementById('addBtns').style.display='flex'; laneConfig.style.display='block'; seek.disabled = false; playerOffsetWrap.style.display = 'none'; }
audio.playbackRate = playbackRate;
try{ await audio.play(); }catch(e){ console.warn(e); }
});
pauseBtn.addEventListener('click', ()=>{ if(mode==='play' && !audio.paused){ alert('Cannot pause in Play mode'); return; } if(audio.paused) audio.play(); else audio.pause(); });
stopBtn.addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; resetRuntimeAll(); rebuildTimeline(); draw(); });
seek.addEventListener('input', ()=>{ seek._dragging = true; });
seek.addEventListener('change', ()=>{ seek._dragging = false; const t = +seek.value || 0; audio.currentTime = t; resetRuntimeFrom(t - 0.05); updateMiniCursor(); draw(); });
// left/right hold to move timeline continuously
let leftInterval=null, rightInterval=null;
document.addEventListener('keydown', e=>{
if(e.repeat) return;
if(e.key === 'ArrowLeft' && !leftInterval){
const step = e.shiftKey ? 0.01 : 0.05;
const fn = ()=>{ if(mode==='play' && !audio.paused) return; const nt = Math.max(0, (audio.currentTime||0) - step); audio.currentTime = nt; resetRuntimeFrom(nt - 0.05); updateMiniCursor(); draw(); };
fn(); leftInterval = setInterval(fn, 60); return;
}
if(e.key === 'ArrowRight' && !rightInterval){
const step = e.shiftKey ? 0.01 : 0.05;
const fn = ()=>{ if(mode==='play' && !audio.paused) return; const nt = Math.min((audio.duration||0), (audio.currentTime||0) + step); audio.currentTime = nt; resetRuntimeFrom(nt - 0.05); updateMiniCursor(); draw(); };
fn(); rightInterval = setInterval(fn, 60); return;
}
// normal keys recorded for judgement
if(e.key.length === 1){
pressed.add(e.key.toUpperCase());
if(!lastPress[e.key.toUpperCase()]) lastPress[e.key.toUpperCase()] = [];
lastPress[e.key.toUpperCase()].push(audio.currentTime || 0);
handleSeqSecondKey(e.key.toUpperCase());
}
if(e.key === '/') { if(mode === 'editor') addNote('tap'); e.preventDefault(); }
// NO Backspace/Delete quick-delete
});
document.addEventListener('keyup', e=>{
if(e.key === 'ArrowLeft' && leftInterval){ clearInterval(leftInterval); leftInterval=null; }
if(e.key === 'ArrowRight' && rightInterval){ clearInterval(rightInterval); rightInterval=null; }
if(e.key.length === 1){ pressed.delete(e.key.toUpperCase()); processHoldReleases(); }
});
// effective lane keys & speed at time t (func applies dynamically)
function effectiveLaneAt(laneIdx, tSec){
const lane = chart.lanes[laneIdx] || { baseKeys: chart.meta.defaultKeys, baseSpeed: chart.meta.defaultSpeed || 600, notes:[] };
let keys = lane.baseKeys || chart.meta.defaultKeys.slice();
let speed = lane.baseSpeed || chart.meta.defaultSpeed || 600;
// find funcs up to time t
const funcs = (lane.notes||[]).filter(n=> n.type==='func' && parseTimeToSec(n.t) <= tSec).sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
if(funcs.length>0){
const last = funcs[funcs.length-1];
if(last.func){
if(Array.isArray(last.func.keys) && last.func.keys.length) keys = last.func.keys.slice();
if(typeof last.func.speed === 'number') speed = last.func.speed;
}
}
return { keys, speed };
}
// runtime reset helpers
function resetRuntimeAll(){
for(const lane of chart.lanes) for(const n of lane.notes){
delete n._judged; delete n._holding; delete n._holdStart; delete n._seqFirst; delete n._seqAwait; delete n._seqSecondGot; delete n._hitFade; delete n._autoFadeTimer;
n.holdProgress = 0;
}
score = 0; multiplier = 1; charge = 0; updateHUD();
}
function resetRuntimeFrom(tSec){
for(const lane of chart.lanes) for(const n of lane.notes) if(parseTimeToSec(n.t) >= tSec){
delete n._judged; delete n._holding; delete n._holdStart; delete n._seqFirst; delete n._seqAwait; delete n._seqSecondGot; delete n._hitFade; delete n._autoFadeTimer;
n.holdProgress = 0;
}
}
// scoring & judge
const BASE = { wonderful:60, 'perfect+':50, perfect:40, good:20 };
function judgeLabel(ms){
const J = { w:+j_w.value, pp:+j_pp.value, p:+j_p.value, g:+j_g.value };
const a = Math.abs(ms);
if(a <= J.w) return 'wonderful';
if(a <= J.pp) return 'perfect+';
if(a <= J.p) return 'perfect';
if(a <= J.g) return 'good';
return null;
}
function applyJudge(label){
if(!label){ multiplier = 1; charge = 0; updateHUD(); lastJudge.textContent='miss'; hudLast.textContent='miss'; return; }
hudLast.textContent = label;
const base = BASE[label] || 0;
score += Math.floor(base * multiplier);
let add = 0;
if(label === 'perfect') add = 1;
if(label === 'perfect+') add = 2;
if(label === 'wonderful') add = 4;
if(label === 'good'){ charge = Math.max(0, charge - 5); if(charge < (multiplier-1)*CHARGE_THRESHOLD) multiplier = Math.max(1, Math.floor(charge/CHARGE_THRESHOLD)+1); updateHUD(); return; }
charge = Math.min(9999, charge + add);
const newMult = Math.min(4, Math.floor(charge/CHARGE_THRESHOLD)+1);
multiplier = Math.max(multiplier, newMult);
updateHUD();
}
// main judge loop uses playerOffsetMs only when mode==='play'
function tickJudge(){
const now = audio.currentTime || 0;
const J = { w:+j_w.value, pp:+j_pp.value, p:+j_p.value, g:+j_g.value, late:+j_late.value };
for(const lane of chart.lanes){
for(const note of lane.notes){
if(note.type === 'func') continue;
const laneIdx = note.lane;
if(note._judged && !note._holding) continue;
const tSec = parseTimeToSec(note.t);
const eff = effectiveLaneAt(laneIdx, tSec);
// compute delta with optional player offset (only applied in play mode)
const deltaMs = (now - tSec) * 1000 - (mode==='play' ? playerOffsetMs : 0);
if(deltaMs > J.g + 1000 && !note._judged){ note._judged = true; note._hitFade = true; applyJudge(null); continue; }
const lbl = judgeLabel(deltaMs);
if(note.type === 'tap'){
if(!note._judged && lbl){
const need = note.keys && note.keys.length ? note.keys : eff.keys;
const ok = need && need.length ? need.some(k=> pressed.has(k)) : false;
if(ok){ note._judged = true; note._hitFade = true; applyJudge(lbl); scheduleFadeClear(note); }
}
} else if(note.type === 'chord'){
const need = note.keys && note.keys.length ? note.keys : eff.keys;
if(need.length <= 1){
if(!note._judged && Math.abs(deltaMs) <= J.g){
const k = need[0];
if(k && pressed.has(k)){ note._judged = true; note._hitFade = true; const base = Math.round((BASE['perfect']||40)*0.5*multiplier); score += base; updateHUD(); scheduleFadeClear(note); }
}
} else {
if(!note._judged && lbl){
const all = need.every(k=> pressed.has(k));
if(all){ note._judged = true; note._hitFade = true; applyJudge(lbl); scheduleFadeClear(note); }
else if(need.some(k=> pressed.has(k))){ note._judged = true; note._hitFade = true; applyJudge('good'); scheduleFadeClear(note); }
}
}
} else if(note.type === 'seq'){
const req = note.keys && note.keys.length ? note.keys : eff.keys;
const first = req[0], second = req[1] || req[0];
if(!note._seqFirst){
if(!note._judged && lbl && first && pressed.has(first)){
note._seqFirst = true; note._seqFirstTime = now; note._judged = true; note._hitFade = true; applyJudge(lbl); note._seqAwait = true; scheduleFadeClear(note);
}
} else if(note._seqAwait){
if(now - (note._seqFirstTime||0) <= ((note.d||1000)/1000)){
if(second && pressed.has(second) && !note._seqSecondGot){ const base = BASE['perfect'] || 40; const extra = Math.round(base*0.2*multiplier); score += extra; note._seqSecondGot = true; updateHUD(); }
} else { note._seqAwait = false; multiplier = 1; charge = 0; updateHUD(); }
}
} else if(note.type === 'hold'){
const need = note.keys && note.keys.length ? note.keys : eff.keys;
const needAll = need && need.length>1;
const isPressedNow = need && need.length ? (needAll ? need.every(k=>pressed.has(k)) : pressed.has(need[0])) : false;
if(!note._judged && lbl && isPressedNow){
// judge at press time only
note._judged = true; note._holding = true; note._holdStart = Math.max(now, tSec);
note._initialLabel = lbl;
const base = BASE[lbl] || 20;
score += Math.floor(base * multiplier);
applyJudge(lbl);
updateHUD();
// do not fade yet
}
if(note._holding){
const holdStart = note._holdStart || tSec;
const held = Math.max(0, Math.min(now, tSec + ((note.d||1000)/1000)) - holdStart);
note.holdProgress = Math.max(0, Math.min(1, held / ((note.d||1000)/1000)));
if(now >= tSec + ((note.d||1000)/1000) && !note._autoFinal){
note._autoFinal = true;
const baseLabel = note._initialLabel || 'good';
const baseVal = BASE[baseLabel] || 20;
// hold total extra: base * 0.5 * completionRatio * multiplier
const totalHoldBonus = Math.round(baseVal * 0.5 * note.holdProgress * multiplier);
const extra = Math.max(0, totalHoldBonus - 0);
if(extra>0){ score += extra; updateHUD(); }
scheduleAutoFade(note); note._holding = false;
}
}
}
}
}
}
// handle hold releases
function processHoldReleases(){
const now = audio.currentTime || 0;
for(const lane of chart.lanes){
for(const note of lane.notes){
if(note.type === 'hold' && note._holding){
const tSec = parseTimeToSec(note.t);
const eff = effectiveLaneAt(note.lane, tSec);
const need = note.keys && note.keys.length ? note.keys : eff.keys;
const still = need && need.length ? (need.length>1 ? need.every(k=>pressed.has(k)) : pressed.has(need[0])) : false;
if(!still){
const holdStart = note._holdStart || tSec;
const held = Math.max(0, Math.min(now, tSec + ((note.d||1000)/1000)) - holdStart);
const ratio = Math.min(1, held / ((note.d||1000)/1000));
note.holdProgress = ratio;
const baseLabel = note._initialLabel || 'good';
const baseVal = BASE[baseLabel] || 20;
const totalHoldBonus = Math.round(baseVal * 0.5 * ratio * multiplier);
const extra = Math.max(0, totalHoldBonus - 0);
if(extra>0){ score += extra; updateHUD(); }
scheduleAutoFade(note);
note._holding = false;
}
}
}
}
}
// seq helper when user presses second key
function handleSeqSecondKey(k){
const now = audio.currentTime || 0;
for(const lane of chart.lanes){
for(const note of lane.notes){
if(note.type === 'seq' && note._seqAwait && !note._seqSecondGot){
if(now - (note._seqFirstTime||0) <= ((note.d||1000)/1000)){
const k2 = note.keys && note.keys[1] ? note.keys[1] : null;
if(k2 && k === k2){
const base = BASE['perfect'] || 40;
const extra = Math.round(base * 0.2 * multiplier);
score += extra; note._seqSecondGot = true; updateHUD();
}
}
}
}
}
}
// fade helpers
function scheduleAutoFade(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 450); }
function scheduleFadeClear(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 250); }
// drawing: judge line moved upward (~75%), notes display keys; thickness default 18
function computeLayout(){ const r = canvas.getBoundingClientRect(); return { x:12, y:12, w:r.width-24, h:r.height-24, judgeY: r.height*0.75 }; }
function draw(){
const L = computeLayout();
ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
ctx.fillStyle = '#021726'; ctx.fillRect(L.x, L.y, L.w, L.h);
const lanes = Math.max(1, chart.lanes.length);
const laneW = (L.w - (lanes+1)*12) / lanes;
const now = audio.currentTime || 0;
// header for each lane
for(let i=0;i<lanes;i++){
const x = L.x + 12 + i*(laneW+12);
ctx.fillStyle = '#071a2b'; ctx.fillRect(x, L.y+8, laneW, L.h-16);
const effNow = effectiveLaneAt(i, now);
ctx.fillStyle = '#9fb8d9'; ctx.font='12px sans-serif';
ctx.fillText(`L${i+1}: ${(effNow.keys||[]).join('+')} @${Math.round(effNow.speed||0)}`, x+8, L.y+26);
// draw judge ground line
ctx.strokeStyle = '#234'; ctx.beginPath(); ctx.moveTo(x, L.y + L.h*0.9); ctx.lineTo(x + laneW, L.y + L.h*0.9); ctx.stroke();
// draw notes in this lane
const laneNotes = chart.lanes[i].notes || [];
for(const n of laneNotes){
const tSec = parseTimeToSec(n.t);
const effForNote = effectiveLaneAt(n.lane, tSec);
const speed = effForNote.speed || chart.lanes[n.lane].baseSpeed || chart.meta.defaultSpeed || 600; // speed in px/sec (we assume)
// we map speed -> pixels per second; allow speed scale: pxPerSec = speed (default 600) -> 300 px per sec mapping
const pxPerSec = speed * 0.5; // tweak factor for visible distance
const dt = tSec - now;
const baseY = L.y + L.h*0.9;
const y = baseY - dt * pxPerSec;
if(y < L.y - 300 || y > L.y + L.h + 300) continue;
const w = laneW*0.8; const left = x + (laneW - w)/2;
ctx.globalAlpha = n._hitFade ? 0.45 : 1.0;
if(n.type === 'func'){
ctx.fillStyle = n.color || '#ffd36b'; ctx.fillRect(left, y-8, w, 16); ctx.fillStyle='#031029'; ctx.fillText('FUNC', left+8, y+4);
} else if(n.type === 'tap' || n.type === 'seq'){
ctx.fillStyle = n.color || '#6ad1ff';
ctx.fillRect(left, y - (n.thickness||18)/2, w, n.thickness||18);
ctx.fillStyle = '#001';
ctx.font = '12px sans-serif';
const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : (effForNote.keys||[]).join('+');
if(displayKeys) ctx.fillText(displayKeys, left+8, y+4);
} else if(n.type === 'chord'){
ctx.fillStyle = n.color || '#ffb265';
ctx.fillRect(left, y - (n.thickness||18)/2, w, n.thickness||18);
ctx.fillStyle='#001'; ctx.font='12px sans-serif';
const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : (effForNote.keys||[]).join('+');
if(displayKeys) ctx.fillText(displayKeys, left+8, y+4);
} else if(n.type === 'hold'){
const yStart = y;
const holdLenSec = (n.d || 1000)/1000.0;
const yEnd = baseY - ((tSec + holdLenSec) - now) * pxPerSec;
const top = Math.min(yStart, yEnd), bottom = Math.max(yStart, yEnd);
const visH = Math.max(8, bottom - top);
let alpha = 1.0;
if(n._holding) alpha = 0.95;
else if(n._hitFade) alpha = 0.6;
ctx.globalAlpha = alpha;
ctx.fillStyle = n.color || '#b38cff';
ctx.fillRect(left, top, w, visH);
if(n.holdProgress){
ctx.fillStyle = 'rgba(255,255,255,0.12)';
ctx.fillRect(left, top, w, visH * n.holdProgress);
}
const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : ((effForNote.keys||[]).join('+'));
if(displayKeys){
ctx.fillStyle = '#001';
ctx.font = '12px sans-serif';
ctx.fillText(displayKeys, left+8, top+14);
}
}
ctx.globalAlpha = 1.0;
}
}
}
// HUD update
function updateHUD(){ scoreEl.textContent = score; multEl.textContent = multiplier; chargeFill.style.width = Math.min(100, Math.round((charge/CHARGE_THRESHOLD)*100)) + '%'; lastJudge.textContent = hudLast.textContent || '-'; }
// main loop
function loop(){
tickJudge();
updateTimeUI();
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// time UI updates
function updateTimeUI(){ const ms = Math.floor((audio.currentTime||0)*1000); timeDisplay.textContent = fmtMs(ms); if(!seek._dragging) seek.value = audio.currentTime || 0; updateMiniCursor(); }
function fmtMs(ms){ const s = Math.floor(ms/1000); const mm = Math.floor(s/60), ss = s%60, mss = ms%1000; return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}.${String(mss).padStart(3,'0')}`; }
function updateMiniCursor(){ const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0))); const cur = document.getElementById('miniCursor'); if(cur) cur.style.left = ((audio.currentTime||0)/dur*100) + '%'; }
// timeline & canvas click
rebuildTimeline();
function rebuildTimeline(){
timeline.innerHTML = '';
const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
for(let s=0;s<dur;s++){
const v = document.createElement('div'); v.style.position='absolute'; v.style.left=(s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background=(s%5===0)?'rgba(255,255,255,0.03)':'rgba(255,255,255,0.01)';
timeline.appendChild(v);
}
chart.lanes.forEach((lane,i)=>{
lane.notes.forEach(n=>{
const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = n.id;
chip.style.top = (6 + i*22) + 'px';
chip.style.left = ((parseTimeToSec(n.t)/dur*100) + '%');
chip.textContent = n.type === 'tap' ? (n.keys? n.keys.join('+') : 'Tap') : (n.type==='hold'?'Hold':n.type.toUpperCase());
chip.style.background = n.color || '#6ad1ff';
chip.addEventListener('click', ev=>{ ev.stopPropagation(); selectNoteById(n.id); showInspector(n); });
timeline.appendChild(chip);
});
});
const cur = document.createElement('div'); cur.id='miniCursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff';
timeline.appendChild(cur);
updateMiniCursor();
}
canvas.addEventListener('click', e=>{
const r = canvas.getBoundingClientRect(); const cx = e.clientX - r.left, cy = e.clientY - r.top;
const L = computeLayout(); const lanes = Math.max(1, chart.lanes.length); const laneW = (L.w - (lanes+1)*12) / lanes; const now = audio.currentTime || 0;
for(let i=0;i<lanes;i++){
const x = L.x + 12 + i*(laneW+12);
for(const n of chart.lanes[i].notes){
const tSec = parseTimeToSec(n.t);
const effForNote = effectiveLaneAt(n.lane, tSec);
const speed = effForNote.speed || chart.lanes[n.lane].baseSpeed || chart.meta.defaultSpeed;
const pxPerSec = (speed || 600)*0.5;
const baseY = L.y + L.h*0.9;
const y = baseY - (tSec - now)*pxPerSec;
const w = laneW*0.8; const left = x + (laneW - w)/2;
if(n.type === 'hold'){
const holdLenSec = (n.d || 1000)/1000.0;
const yEnd = baseY - ((tSec + holdLenSec) - now) * pxPerSec;
const top = Math.min(y, yEnd), bottom = Math.max(y, yEnd);
if(cx>=left && cx <= left+w && cy >= top && cy <= bottom){ selectNoteById(n.id); showInspector(n); return; }
} else {
const th = n.thickness||18;
if(cx>=left && cx <= left+w && cy >= y-th/2 && cy <= y+th/2){ selectNoteById(n.id); showInspector(n); return; }
}
}
}
selectedNote = null; hideInspector();
});
timeline.addEventListener('click', e=>{
const rect = timeline.getBoundingClientRect();
const x = e.clientX - rect.left;
const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
const t = (x / timeline.clientWidth) * dur;
if(mode === 'play' && !audio.paused){ alert('Cannot seek during Play'); return; }
audio.currentTime = t;
resetRuntimeFrom(t - 0.05);
updateMiniCursor(); draw();
});
// get chart end
function getChartEnd(){ let m=0; chart.lanes.forEach(l=> l.notes.forEach(n=> m = Math.max(m, parseTimeToSec(n.t) + (n.d? n.d/1000:0)))); return m; }
// schedule fade utils (needed earlier)
function scheduleAutoFade(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 450); }
function scheduleFadeClear(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 250); }
// update HUD
function updateHUD(){ scoreEl.textContent = score; multEl.textContent = multiplier; chargeFill.style.width = Math.min(100, Math.round((charge/CHARGE_THRESHOLD)*100)) + '%'; lastJudge.textContent = hudLast.textContent || '-'; }
// keyboard: no backspace delete hookup (delete only in inspector)
document.addEventListener('keydown', e=>{
if(e.repeat) return;
if(e.key.length === 1){
pressed.add(e.key.toUpperCase());
if(!lastPress[e.key.toUpperCase()]) lastPress[e.key.toUpperCase()] = [];
lastPress[e.key.toUpperCase()].push(audio.currentTime || 0);
handleSeqSecondKey(e.key.toUpperCase());
}
});
// player offset UI visibility
modeSelect.addEventListener('change', e=>{
mode = e.target.value;
if(mode === 'play'){ playerOffsetWrap.style.display = 'flex'; document.getElementById('addBtns').style.display='none'; laneConfig.style.display='none'; seek.disabled = true; }
else { playerOffsetWrap.style.display = 'none'; document.getElementById('addBtns').style.display='flex'; laneConfig.style.display='block'; seek.disabled = false; }
});
playerOffsetInput.addEventListener('input', e=>{ playerOffsetMs = +(e.target.value||0); });
// init UI reactive wiring
laneCount.addEventListener('change', ()=> ensureLanes(+laneCount.value));
rebuildLaneUI(); rebuildCurrentLaneOptions();
// seek handling and left/right drag already added earlier (keep)
// exposed for debug
window._chart = chart;
window._parseTimeToSec = parseTimeToSec;
console.log('RhythmStudio full loaded.');
</script>
</body>
</html>