Files
yellowberry_MercuryPlayer/main.js
T
2021-10-07 00:44:26 +08:00

1044 lines
38 KiB
JavaScript

//window.addEventListener('load', function () {
let maxR
/** @type {CanvasRenderingContext2D} */
const ctx = canvas.getContext('2d');
/**
* 1: touch
* 2: bonus touch
* 3: snap inward
* 4: snap outward
* 5: flick left
* 6: flick left with circle effect
* 7: flick right
* 8: flick right with circle effect
* 9: hold start
* 10: hold step
* 11: hold end
* 12: lane effect
* 13: lane effect
* 14: end of chart
* 16: chain
* 20: touch R
* 21: snap inward R
* 22: snap outward R
* 23: flick left R
* 24: flick right R
* 25: hold start R
* 26: chain R
*/
let noteList = [];
/**
* 2: bpm
* 3: met
* 5: sfl
* 6: reverse start
* 7: reverse point
* 8: reverse end
* 9: sfl 0
* 10: sfl 1
*/
let controlList = [];
let noteListForPlayback = [];
let sflTsList = []
let laneToggleList = []
let idOffsetMap = []
let prevIdMap = {}
let chartHeader = {}
let reverseSection = []
let holdList = []
const arrowCanvas = {
in: document.createElement('canvas'),
out: document.createElement('canvas'),
left: document.createElement('canvas'),
right: document.createElement('canvas'),
}
function createArrows() {
arrowCanvas.in.width = maxR*2, arrowCanvas.in.height = maxR*2
arrowCanvas.out.width = maxR*2, arrowCanvas.out.height = maxR*2
arrowCanvas.left.width = maxR*2, arrowCanvas.left.height = maxR*2
arrowCanvas.right.width = maxR*2, arrowCanvas.right.height = maxR*2
const ctx = {
in: arrowCanvas.in.getContext('2d'),
out: arrowCanvas.out.getContext('2d'),
left: arrowCanvas.left.getContext('2d'),
right: arrowCanvas.right.getContext('2d'),
}
const borderWidth = 12 * devicePixelRatio, colorWidth = 5 * devicePixelRatio
ctx.in.translate(maxR, maxR); ctx.in.rotate(Math.PI / 2); ctx.in.translate(-maxR, -maxR)
for (let i = 0; i < 30; i++) {
ctx.in.beginPath()
ctx.in.arc(maxR, maxR, maxR * 0.95, (i+0.25) * Math.PI / 15, (i+0.25) * Math.PI / 15)
ctx.in.arc(maxR, maxR, maxR * 0.85, (i+0.5) * Math.PI / 15, (i+0.5) * Math.PI / 15)
ctx.in.arc(maxR, maxR, maxR * 0.95, (i+0.75) * Math.PI / 15, (i+0.75) * Math.PI / 15)
ctx.in.strokeStyle = 'rgb(200,200,200)'; ctx.in.lineWidth = borderWidth; ctx.in.stroke()
ctx.in.strokeStyle = 'rgb(203,29,25)'; ctx.in.lineWidth = colorWidth; ctx.in.stroke()
}
ctx.out.translate(maxR, maxR); ctx.out.rotate(Math.PI / 2); ctx.out.translate(-maxR, -maxR)
for (let i = 0; i < 30; i++) {
ctx.out.beginPath()
ctx.out.arc(maxR, maxR, maxR * 0.85, (i+0.25) * Math.PI / 15, (i+0.25) * Math.PI / 15)
ctx.out.arc(maxR, maxR, maxR * 0.95, (i+0.5) * Math.PI / 15, (i+0.5) * Math.PI / 15)
ctx.out.arc(maxR, maxR, maxR * 0.85, (i+0.75) * Math.PI / 15, (i+0.75) * Math.PI / 15)
ctx.out.strokeStyle = 'rgb(200,200,200)'; ctx.out.lineWidth = borderWidth; ctx.out.stroke()
ctx.out.strokeStyle = 'rgb(33,180,251)'; ctx.out.lineWidth = colorWidth; ctx.out.stroke()
}
ctx.left.translate(maxR, maxR); ctx.left.rotate(Math.PI / 2); ctx.left.translate(-maxR, -maxR)
for (let i = 0; i < 30; i++) {
ctx.left.beginPath()
ctx.left.arc(maxR, maxR, maxR * 0.85, (i+0.3) * Math.PI / 15, (i+0.3) * Math.PI / 15)
ctx.left.arc(maxR, maxR, maxR * 0.90, (i+0.7) * Math.PI / 15, (i+0.7) * Math.PI / 15)
ctx.left.arc(maxR, maxR, maxR * 0.95, (i+0.3) * Math.PI / 15, (i+0.3) * Math.PI / 15)
ctx.left.strokeStyle = 'rgb(200,200,200)'; ctx.left.lineWidth = borderWidth; ctx.left.stroke()
ctx.left.strokeStyle = 'rgb(246,159,55)'; ctx.left.lineWidth = colorWidth; ctx.left.stroke()
}
ctx.right.translate(maxR, maxR); ctx.right.rotate(Math.PI / 2); ctx.right.translate(-maxR, -maxR)
for (let i = 0; i < 30; i++) {
ctx.right.beginPath()
ctx.right.arc(maxR, maxR, maxR * 0.85, (i+0.7) * Math.PI / 15, (i+0.7) * Math.PI / 15)
ctx.right.arc(maxR, maxR, maxR * 0.90, (i+0.3) * Math.PI / 15, (i+0.3) * Math.PI / 15)
ctx.right.arc(maxR, maxR, maxR * 0.95, (i+0.7) * Math.PI / 15, (i+0.7) * Math.PI / 15)
ctx.right.strokeStyle = 'rgb(200,200,200)'; ctx.right.lineWidth = borderWidth; ctx.right.stroke()
ctx.right.strokeStyle = 'rgb(98,251,43)'; ctx.right.lineWidth = colorWidth; ctx.right.stroke()
}
}
const enableBga = false
const TICK_PER_GAME_SECTION = 1920;
const TICK_PER_BEAT = TICK_PER_GAME_SECTION / 3.8;
let RENDER_DISTANCE = 750
//parseNotesFromFile('MusicData/S00-003/S00-003_02.mer'); setBgm('0.wav')
//parseNotesFromFile('MusicData/S02-085/S02-085_02.mer')
//parseNotesFromFile('MusicData/S02-225/S02-225_02.mer')
//parseNotesFromFile('MusicData/S02-218/S02-218_02.mer'); setBgm('music001282.wav')
//parseNotesFromFile('MusicData/S00-004/S00-004_02.mer'); setBgm('4.wav')
//parseNotesFromFile('MusicData/S01-055/S01-055_02 (2).mer')
//const playbackId = 'S01-093'
//parseNotesFromFile(`MusicData/${playbackId}/${playbackId}_02.mer`); setBgm('Sound/Bgm/output/MER_BGM_'+playbackId.replace('-', '_')+'.m4a')
let musicTable
function cutText(s) {
const charRenderSize = s.split('').map(c=> c.match(/[a-zA-Z0-9 ]/)?1:2)
const renderLengthTotal = charRenderSize.reduce((s,v) => s+v, 0)
if (renderLengthTotal < 30) return s
let renderLength = 0
for (let i=0; i<charRenderSize.length; i++) {
renderLength += charRenderSize[i]
if (renderLength > 25) return s.substr(0, i) + '...'
}
}
fetch('Table/music_info.json').then(r=>r.json()).then(r => {
musicTable = {}
music_select.parentNode.style.display = ''
music_file.parentNode.style.display = 'none'
const keys = Object.keys(r)
keys.sort((a,b) => a>b?1:-1)
keys.forEach(open => {
const option = music_select.appendChild(document.createElement('option'))
option.setAttribute('disabled', '')
option.textContent = open
r[open].sort((a,b) => a.id-b.id)
r[open].forEach(music => {
musicTable[music.id] = music;
const option = music_select.appendChild(document.createElement('option'))
option.value = music.id
diffi = [music.DifficultyNormalLv, music.DifficultyHardLv, music.DifficultyExtremeLv]
if (music.DifficultyInfernoLv != '0') diffi.push(music.DifficultyInfernoLv)
let title = cutText(music.title), artist = cutText(music.artist)
option.textContent = `${music.AssetDirectory} (${diffi.join('/')}) ${title} - ${artist}`
})
})
music_select.value = music_select.children[1].value
}).catch(e => {
console.error('failed loading music table', e)
})
function loadUsingSelect() {
const id = music_select.value | 0
const diffi = diffi_select.value | 0
if (!musicTable[id]) return alert('no such music id')
if (diffi < 0 || diffi > 3 || (diffi == 3 && musicTable[id].DifficultyInfernoLv == '0')) return alert('no such difficulty level')
stop()
const strId = musicTable[id].AssetDirectory
parseNotesFromFile(`MusicData/${strId}/${strId}_0${diffi}.mer`)
setBgm('Sound/Bgm/output/MER_BGM_'+strId.replace('-', '_')+'.m4a')
}
function loadUsingFile() {
if (music_file.files.length && bgm_file.files.length) {
const reader = new FileReader()
reader.readAsText(music_file.files[0], 'UTF-8')
reader.onload = e => parseNotesFromText(reader.result)
bgm.src = URL.createObjectURL(bgm_file.files[0])
} else {
alert('choose file')
}
}
music_select.addEventListener('change', e => {
if (!musicTable[music_select.value]) return
const music = musicTable[music_select.value]
diffi_select.children[0].textContent = `Normal (${music.DifficultyNormalLv}) - ${music.NotesDesignerNormal}`
diffi_select.children[1].textContent = `Hard (${music.DifficultyHardLv}) - ${music.NotesDesignerHard}`
diffi_select.children[2].textContent = `Expert (${music.DifficultyExtremeLv}) - ${music.NotesDesignerExpert}`
if (music.DifficultyInfernoLv == "0") {
diffi_select.children[3].setAttribute('disabled', '')
diffi_select.children[3].textContent = 'Inferno'
if (diffi_select.value === '3') diffi_select.value = '2'
} else {
diffi_select.children[3].removeAttribute('disabled')
diffi_select.children[3].textContent = `Inferno (${music.DifficultyInfernoLv}) - ${music.NotesDesignerInferno}`
}
})
function setBgm(path) {
bgm.src = path
}
function parseNotesFromFile(file) {
fetch(file).then(r=>r.text()).then(parseNotesFromText)
}
function tickFromSectionAndTick(section, tick) {
return section * 1920 + tick;
}
function parseNotesFromText(text) {noteList = [];
noteList = []
controlList = []
noteListForPlayback = []
sflTsList = []
idOffsetMap = []
prevIdMap = {}
chartHeader = {}
reverseSection = []
holdList = []
const controlDupFix = {}
const lines = text.trim().replace(/ +/g, '\t').split('\n');
let lastEventTick = 0;
let nextHoldMap = {}
const holdFillTickGap = 3
const tickNoteCount = {}
lines.forEach(line => {
line = line.trim().split('\t');
// ignore headers
if (line[0].substr(0, 1) == '#') {
const headerName = line.shift().substr(1)
chartHeader[headerName] = line.join(' ')
return;
}
// actual note
if (line[2] == '1') {
const note = {
section: parseInt(line[0]),
tick: parseInt(line[1]),
tickTotal: 0,
noteType: line[3],
id: parseInt(line[4]),
laneOffset: parseInt(line[5]),
noteWidth: parseInt(line[6]),
extParam1: parseInt(line[7]),
extParam2: line[8] != undefined ? parseInt(line[8]) : null
}
note.tickTotal = tickFromSectionAndTick(note.section, note.tick);
if (['10','11','12','13','16','26'].indexOf(note.noteType) === -1) {
if (!tickNoteCount[note.tickTotal]) tickNoteCount[note.tickTotal] = 0
tickNoteCount[note.tickTotal]++
}
note.hasSameTime = false
noteList.push(note)
lastEventTick = Math.max(lastEventTick, note.tickTotal)
if (note.noteType == '9' || note.noteType == '10' || note.noteType == '25') {
prevIdMap[note.extParam2] = note.id
}
} else {
const control = {
section: parseInt(line[0]),
tick: parseInt(line[1]),
tickTotal: 0,
cmdType: line[2],
value1: parseFloat(line[3]),
value2: line[4] != undefined ? parseFloat(line[4]) : null,
}
const controlId = line[0]+'_'+line[1]+'_'+line[2]
control.tickTotal = tickFromSectionAndTick(control.section, control.tick);
if (controlDupFix[controlId]) {
controlList[controlDupFix[controlId]] = control
} else {
controlDupFix[controlId] = controlList.length
controlList.push(control)
}
if (control.cmdType == '9') {
control.cmdType = '5'
control.value1 = 0
}
if (control.cmdType == '10') {
control.cmdType = '5'
control.value1 = 1
}
noteList.push(control)
lastEventTick = Math.max(lastEventTick, control.tickTotal)
}
})
lastEventTick++;
// add section seperator
for (let i=0; i <= lastEventTick; i+= 1920) {
noteList.push({
section: i / 1920,
tick: 0,
tickTotal: i,
noteType: 'sectionSep',
})
}
noteList = noteList.sort((a, b) => a.tickTotal - b.tickTotal)
let noteNo = 0
let timeStampOffset = 0;
const bpmList = controlList.filter(i => i.cmdType === '2')
const metList = controlList.filter(i => i.cmdType === '3')
let metOffset = 0
let TICK_PER_BEAT = TICK_PER_GAME_SECTION / 4
{
const currentMet = metList[metOffset]
const beatPerSectionMul = currentMet.value1
const beatPerSectionDiv = currentMet.value2 == null ? 4 : currentMet.value2
TICK_PER_BEAT = TICK_PER_GAME_SECTION / beatPerSectionMul * beatPerSectionDiv / 4
}
// convert section/tick to milli second
for (let i = 0; i < bpmList.length; i++) {
const currentBpm = bpmList[i]
let fromTick = currentBpm.tickTotal
const toTick = i == bpmList.length - 1 ? lastEventTick : bpmList[i + 1].tickTotal
const bpm = currentBpm.value1
for (; noteNo < noteList.length; noteNo++) {
const currentNote = noteList[noteNo]
if (currentNote.tickTotal > toTick) break;
if (metOffset < metList.length - 1 && currentNote.tickTotal > metList[metOffset + 1].tickTotal) {
metOffset++
timeStampOffset += Math.round((metList[metOffset].tickTotal - fromTick) / TICK_PER_BEAT * 60000 / bpm);
fromTick = metList[metOffset].tickTotal
const currentMet = metList[metOffset]
const beatPerSectionMul = currentMet.value1
const beatPerSectionDiv = currentMet.value2 == null ? 4 : currentMet.value2
TICK_PER_BEAT = TICK_PER_GAME_SECTION / beatPerSectionMul * beatPerSectionDiv / 4
}
currentNote.timestamp = timeStampOffset + Math.round((currentNote.tickTotal - fromTick) / TICK_PER_BEAT * 60000 / bpm);
if (currentNote.noteType !== undefined) noteListForPlayback.push(currentNote);
if (currentNote.noteType === '14') chartLength = currentNote.timestamp
if (tickNoteCount[currentNote.tickTotal] > 1 && ['sectionSep', '10', '11', '12', '13', '16', '26'].indexOf(currentNote.noteType) === -1) currentNote.hasSameTime = true
}
timeStampOffset += Math.round((toTick - fromTick) / TICK_PER_BEAT * 60000 / bpm);
}
const sflList = controlList.filter(i => i.cmdType === '5')
noteNo = 0;
let distanceOffset = 0;
sflList.unshift({tickTotal:0, value1:1, timestamp:0})
// convert milli second to distance(?) for chart speed control
for (let i = 0; i < sflList.length; i++) {
const currentSfl = sflList[i]
const fromTs = currentSfl.timestamp
const toTs = i == sflList.length - 1 ? Infinity : sflList[i + 1].timestamp
const sfl = currentSfl.value1
for (; noteNo < noteListForPlayback.length; noteNo++) {
const currentNote = noteListForPlayback[noteNo]
if (currentNote.timestamp > toTs) break;
currentNote.distance = distanceOffset + Math.round((currentNote.timestamp - fromTs) * sfl);
}
sflTsList.push({
timestamp: fromTs,
distance: distanceOffset,
sfl
})
distanceOffset += Math.round((toTs - fromTs) * sfl);
}
// sort by distance
noteListForPlayback = noteListForPlayback.sort((a, b) => a.distance - b.distance)
for (let i=0; i<noteListForPlayback.length; i++) {
idOffsetMap[noteListForPlayback[i].id] = i
}
// fix hold chain
noteListForPlayback.forEach(i => {
if (i.noteType === '9' || i.noteType === '25') {
const holdChain = [i]
const changePoint = [0]
let prevOffset = i.laneOffset
let laneOffsetAdjust = 0
while (i.noteType !== '11') {
i = noteListForPlayback[idOffsetMap[i.extParam2]]
if (i.extParam1 === 1) changePoint.push(holdChain.length)
holdChain.push(i)
if (prevOffset == 59 && i.laneOffset == 0) laneOffsetAdjust += 60
else if (prevOffset == 0 && i.laneOffset == 59) laneOffsetAdjust -= 60
prevOffset = i.laneOffset
i.laneOffset += laneOffsetAdjust
}
//console.log(holdChain, changePoint)
for (let j = 0; j < changePoint.length - 1; j++) {
const startIndex = changePoint[j], endIndex = changePoint[j + 1], segments = endIndex - startIndex
const startOffset = holdChain[startIndex].laneOffset, startWidth = holdChain[startIndex].noteWidth
const endOffset = holdChain[endIndex].laneOffset, endWidth = holdChain[endIndex].noteWidth
for (let k = startIndex + 1; k < endIndex; k++) {
holdChain[k].laneOffset = (endOffset - startOffset) * (k - startIndex) / segments + startOffset
holdChain[k].noteWidth = (endWidth - startWidth) * (k - startIndex) / segments + startWidth
}
}
let distanceMax = 0, distanceMin = Infinity
let timeStamp = Infinity, timeStampEnd = 0
const hold = {
nodes: holdChain.map(i => {
distanceMax = Math.max(i.distance, distanceMax)
distanceMin = Math.min(i.distance, distanceMin)
timeStamp = Math.min(i.timestamp, timeStamp)
timeStampEnd = Math.max(i.timestamp, timeStampEnd)
return {
distance: i.distance,
timestamp: i.timestamp,
laneOffset: i.laneOffset,
noteWidth: i.noteWidth
}
}),
distanceMin,
distanceMax,
timeStamp,
timeStampEnd,
}
holdList.push(hold)
}
})
// remove hold node & end from list
noteListForPlayback = noteListForPlayback.filter(i => i.noteType !== '10' && i.noteType !== '11')
// reverse section
const reverseStartList = controlList.filter(i => i.cmdType === '6')
const reversePointList = controlList.filter(i => i.cmdType === '7')
const reverseEndList = controlList.filter(i => i.cmdType === '8')
for (let i=0; i<reverseStartList.length; i++) {
reverseSection.push([
reverseStartList[i].timestamp,
reversePointList[i].timestamp,
reverseEndList[i].timestamp,
])
}
laneToggleList = noteList.filter(i => (i.noteType === '12' || i.noteType === '13'))
console.log('parsed notes')
window.noteTypes = {}
noteListForPlayback.forEach(i => {
if (!window.noteTypes[i.noteType]) window.noteTypes[i.noteType] = []
window.noteTypes[i.noteType].push(i)
})
}
const drawCount = {
frame: 0,
actualFrame: 0,
}
let currentSectionDiv = stats.parentNode.insertBefore(document.createElement('div'), stats)
toggle_ui.addEventListener('input', () => {
document.body.classList[toggle_ui.checked ? 'add' : 'remove']('hide-control')
})
toggle_long_audio.addEventListener('input', () => {
document.body.classList[toggle_long_audio.checked ? 'add' : 'remove']('long-audio')
})
setInterval(() => {
stats.textContent = [
`frame draw: ${drawCount.frame}`,
`frame actual draw: ${drawCount.actualFrame}`,
].join('\n')
drawCount.frame = 0
drawCount.actualFrame = 0
}, 1e3)
let startTs = 0
let startNextFrame = false
let currentTs = 0
let currentDistance = 0;
let currentSection = 0;
let playing = false;
let sfl = 1
let sflOffset = 0
let drawForNextFrame = false
let NOTE_APPEAR_DISTANCE = 1
let NOTE_SPEED_POWER = 1.95
let chartLength = 0
const laneEffectMul = 1
const laneOnState = new Uint8Array(60 * laneEffectMul)
function render(now) {
requestAnimationFrame(render)
drawCount.frame++
if (!playing) {
if (!drawForNextFrame) {
return
}
drawForNextFrame = false
}
drawCount.actualFrame++
ctx.clearRect(0, 0, canvas.width, canvas.height)
const centerX = canvas.width / 2, centerY = canvas.height / 2
// outer ring
ctx.lineWidth = 3
ctx.strokeStyle = '#000000'
ctx.beginPath();
ctx.arc(centerX, centerY, maxR, 0, Math.PI * 2)
ctx.stroke()
if (enableBga) {
ctx.fillStyle = 'rgba(0,0,0,0.8)'
ctx.fill()
} else {
ctx.fillStyle = 'rgba(32,32,32,0.8)'
ctx.fill()
}
// lanes
const laneGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxR)
laneGradient.addColorStop(0, 'rgba(128,128,128,0)');
laneGradient.addColorStop(0.1, 'rgba(128,128,128,0)');
laneGradient.addColorStop(0.2, 'rgba(128,128,128,0.3)');
laneGradient.addColorStop(1, 'rgba(128,128,128,0.8)');
ctx.lineWidth = 1 * devicePixelRatio
ctx.strokeStyle = laneGradient
ctx.beginPath();
for (let i=0; i<30; i++) {
const degree = Math.PI * i / 30;
const x1 = centerX + Math.sin(degree) * maxR, y1 = centerY + Math.cos(degree) * maxR
const x2 = centerX - Math.sin(degree) * maxR, y2 = centerY - Math.cos(degree) * maxR
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
}
ctx.stroke()
let drawDistance = RENDER_DISTANCE
let previousTs = currentTs
if (playing) {
if (startNextFrame) {
startNextFrame = false
startTs = now - currentTs
if (chartHeader.OFFSET) {
const offset = parseFloat(chartHeader.OFFSET)
if (!isNaN(offset)) {
startTs -= offset * 1000
}
}
updateLaneOnState(-1, currentTs)
}
if (!(currentTs > sflTsList[sflOffset].timestamp && (sflOffset === sflTsList.length - 1 || currentTs <= sflTsList[sflOffset + 1].timestamp))) {
for (sflOffset = 0; sflOffset < sflTsList.length - 1; sflOffset++) {
if (currentTs > sflTsList[sflOffset].timestamp && currentTs <= sflTsList[sflOffset + 1].timestamp) {
break;
}
}
sfl = sflTsList[sflOffset].sfl
}
currentTs = now - startTs
let calcBaseTs = currentTs
for (let i=0; i<reverseSection.length; i++) {
if (calcBaseTs > reverseSection[i][0] && calcBaseTs < reverseSection[i][1]) {
calcBaseTs = reverseSection[i][1] + (reverseSection[i][1] - calcBaseTs) * (reverseSection[i][2] - reverseSection[i][1]) / (reverseSection[i][1] - reverseSection[i][0])
drawDistance = Math.min(drawDistance, reverseSection[i][2] - calcBaseTs)
break
}
}
currentDistance = (calcBaseTs - sflTsList[sflOffset].timestamp) * sfl + sflTsList[sflOffset].distance
}
if (noteListForPlayback.length) {
currentSection = noteListForPlayback.filter(i => i.noteType === 'sectionSep' && i.timestamp <= currentTs).pop().section
}
currentSectionDiv.textContent = `current section: ${currentSection}`
updateLaneOnState(previousTs, currentTs)
// black out "off" lanes
const laneBgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxR)
laneBgGradient.addColorStop(0, 'rgba(255,255,255,0)');
laneBgGradient.addColorStop(0.2, 'rgba(255,255,255,0.1)');
laneBgGradient.addColorStop(1, enableBga ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.4)');
ctx.fillStyle = laneBgGradient
ctx.beginPath()
for (let i = 0; i < 60 * laneEffectMul; i++) {
if (laneOnState[i] == 0) continue
const start = 60 * laneEffectMul - i - 1, end = 60 * laneEffectMul - i
ctx.moveTo(centerX, centerY)
ctx.arc(
centerX, centerY,
maxR,
Math.PI * (start / 30 / laneEffectMul), Math.PI * (end / 30 / laneEffectMul)
)
ctx.lineTo(centerX, centerY)
}
ctx.fill()
const notesToRenderArr = getNotesForDraw(currentDistance, drawDistance).filter(i => i.timestamp >= currentTs)
notesToRender = {
sectionSep: [],
touch: [],
hold: [],
holdBody: [],
chain: [],
flickL: [],
flickR: [],
snapIn: [],
snapOut: [],
arrow: [],
R: [],
laneEffect: [],
sameTime: [],
unknown: []
}
notesToRenderArr.forEach(i => {
switch (i.noteType) {
case 'sectionSep': {notesToRender.sectionSep.push(i); break;}
case '1': // touch
case '2': // bonus touch
case '20': {notesToRender.touch.push(i); break;} // touch R
case '3': case '21': {notesToRender.arrow.push(i); notesToRender.snapIn.push(i); break;}
case '4': case '22': {notesToRender.arrow.push(i); notesToRender.snapOut.push(i); break;}
case '5': // flick L
case '6': // flick L with effect
case '23': {notesToRender.arrow.push(i); notesToRender.flickL.push(i); break;} // flick Left R
case '7': // flick R
case '8': // flick R with effect
case '24': {notesToRender.arrow.push(i); notesToRender.flickR.push(i); break;} // flick Right R
case '9': // hold start
case '25': // hold start R
case '11-': {notesToRender.hold.push(i);} // hold end
case '10': {notesToRender.holdBody.push(i); break;} // hold body
case '12':
case '13': {notesToRender.laneEffect.push(i); break;}
case '16': case '26': {notesToRender.chain.push(i); break;}
}
if (i.noteType >= '20' && i.noteType <= '26') {
notesToRender.R.push(i);
}
if (i.hasSameTime) {
notesToRender.sameTime.push(i)
}
})
if (notesToRender.sectionSep.length) {
const thicc = 0.5 * devicePixelRatio
ctx.strokeStyle = '#BBB'
notesToRender.sectionSep.forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = (r * 6 / maxR + 0.5) * thicc
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
0, Math.PI * 2
)
ctx.stroke()
})
}
{
const drawHoldList = getHoldsForDraw(currentDistance, currentTs, drawDistance);
ctx.fillStyle = 'rgba(207,162,93, 0.7)'
drawHoldList.forEach(nodes => {
ctx.beginPath()
const nodeCount = nodes.length
if (nodes[0].distance < currentDistance) { // clip to outer ring
let clipDistance = currentDistance
let clipOffset = (nodes[1].laneOffset - nodes[0].laneOffset) * (clipDistance - nodes[0].distance) / (nodes[1].distance - nodes[0].distance) + nodes[0].laneOffset
let clipWidth = (nodes[1].noteWidth - nodes[0].noteWidth) * (clipDistance - nodes[0].distance) / (nodes[1].distance - nodes[0].distance) + nodes[0].noteWidth
nodes[0] = {
distance: clipDistance,
laneOffset: clipOffset,
noteWidth: clipWidth,
}
}
if (nodes[nodeCount - 1].distance > currentDistance+RENDER_DISTANCE) { // clip to center
let clipDistance = currentDistance+RENDER_DISTANCE
let clipOffset = (nodes[nodeCount - 1].laneOffset - nodes[nodeCount - 2].laneOffset) * (clipDistance - nodes[nodeCount - 2].distance) / (nodes[nodeCount - 1].distance - nodes[nodeCount - 2].distance) + nodes[nodeCount - 2].laneOffset
let clipWidth = (nodes[nodeCount - 1].noteWidth - nodes[nodeCount - 2].noteWidth) * (clipDistance - nodes[nodeCount - 2].distance) / (nodes[nodeCount - 1].distance - nodes[nodeCount - 2].distance) + nodes[nodeCount - 2].noteWidth
nodes[nodeCount - 1] = {
distance: clipDistance,
laneOffset: clipOffset,
noteWidth: clipWidth,
}
}
for (let i=0; i<nodeCount-1; i++) {
const r = distanceToRenderRadius(maxR, Math.max(nodes[i].distance - currentDistance, 0) / RENDER_DISTANCE)
const start = 60 - nodes[i].laneOffset - nodes[i].noteWidth, end = 60 - nodes[i].laneOffset
if (i === 0) {
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30), Math.PI * (end / 30)
)
} else {
ctx.arc(
centerX, centerY,
r,
Math.PI * (end / 30), Math.PI * (end / 30)
)
}
}
for (let i=nodeCount-1; i>=0; i--) {
const r = distanceToRenderRadius(maxR, Math.min(nodes[i].distance - currentDistance, RENDER_DISTANCE) / RENDER_DISTANCE)
const start = 60 - nodes[i].laneOffset - nodes[i].noteWidth, end = 60 - nodes[i].laneOffset
if (i === nodeCount-1) {
ctx.arc(
centerX, centerY,
r,
Math.PI * (end / 30), Math.PI * (start / 30),
true
)
} else {
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30), Math.PI * (start / 30)
)
}
}
ctx.closePath()
ctx.fill()
})
}
// blue extend for same time notes
{
const key = 'sameTime', color = 'rgb(80,255,250)'
const thicc = 4 * devicePixelRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = (r * 6 / maxR + 0.5) * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30), Math.PI * (end / 30)
)
ctx.stroke()
})
}
}
// white border for R notes
{
const key = 'R', color = 'rgb(255,255,255)'
const thicc = 5 * devicePixelRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = (r * 6 / maxR + 0.5) * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
const cutOut = i.noteWidth < 60 ? 0.01 : 0
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30) + cutOut, Math.PI * (end / 30) - cutOut
)
ctx.stroke()
})
}
}
const colorMap = [
['hold', 'rgb(88,75,47)'],
['touch', 'rgb(216,45,184)'],
['chain', 'rgb(157,122,40)'],
['flickL', 'rgb(246,159,55)'],
['flickR', 'rgb(98,251,43)'],
['snapIn', 'rgb(203,29,25)'],
['snapOut', 'rgb(33,180,251)'],
]
colorMap.forEach(noteType => {
const key = noteType[0], color = noteType[1]
const thicc = (['flickL','flickR','snapIn','snapOut'].indexOf(key) === -1 ? 2.25 : 3) * devicePixelRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = (r * 6 / maxR + 0.5) * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
const cutOut = i.noteWidth < 60 ? 0.03 : 0
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30) + cutOut, Math.PI * (end / 30) - cutOut
)
ctx.stroke()
})
}
})
if (notesToRender.arrow.length) {
for (let i=notesToRender.arrow.length-1; i>=0; i--) {
const a = notesToRender.arrow[i]
const r = distanceToRenderRadius(maxR*0.95, (a.distance - currentDistance) / RENDER_DISTANCE)
const start = 60 - a.laneOffset - a.noteWidth, end = 60 - a.laneOffset
ctx.save()
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30), Math.PI * (end / 30)
)
ctx.moveTo(centerX, centerY)
ctx.clip()
switch (a.noteType) {
case '3': case '21': {
ctx.drawImage(arrowCanvas.in, centerX-r, centerY-r, r*2, r*2)
break
}
case '4': case '22': {
ctx.drawImage(arrowCanvas.out, centerX-r, centerY-r, r*2, r*2)
break
}
case '5': case '6': case '23': {
ctx.drawImage(arrowCanvas.left, centerX-r, centerY-r, r*2, r*2)
break
}
case '7': case '8': case '24': {
ctx.drawImage(arrowCanvas.right, centerX-r, centerY-r, r*2, r*2)
break
}
}
ctx.restore()
}
}
{
let chartRemaining = 1
if (chartLength) {
chartRemaining = Math.max(0, 1 - currentTs / chartLength)
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)'
const r = maxR * 0.98
ctx.lineWidth = (r * 5 / maxR + 2) * devicePixelRatio
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
Math.PI * (-0.5 - chartRemaining * 2), -Math.PI * 0.5
)
ctx.stroke()
}
}
let CALC_CONE_HEIGHT = 6
let CALC_CONE_RADIUS = 2
function distanceToRenderRadius (maxR, distance) {
let h = distance * CALC_CONE_HEIGHT + CALC_CONE_RADIUS
let a = (1 - distance) * CALC_CONE_RADIUS
let angle = Math.atan(h / a) * 2 / Math.PI
return maxR * (1 - angle) * 2
}
window.play = function () {
startNextFrame = true
bgm.play()
if (enableBga) bga.play()
bga.muted = true
currentTs = Math.round(bgm.currentTime * 1000)
playing = true
}
window.pause = function () {
bgm.pause()
if (enableBga) bga.pause()
playing = false
}
window.stop = function () {
pause()
currentDistance = 0
currentTs = 0
bgm.currentTime = 0
if (enableBga) bga.currentTime = 0
drawForNextFrame = true
sflOffset = 0
sfl = 1
}
window.setPlaybackTime = function (time = 0) {
currentTs = Math.round(time * 1000)
bgm.currentTime = time
}
requestAnimationFrame(render)
function getNotesForDraw(currentDistance, renderDistance = RENDER_DISTANCE) {
if (!noteListForPlayback.length) return []
if (currentDistance > noteListForPlayback[noteListForPlayback.length - 1].distance) return []
//return []
// search sub array start
let startOffset, endOffset
{
let head = 0, tail = noteListForPlayback.length - 1
let mid
while (head <= tail) {
mid = Math.floor((head + tail) / 2)
const result = currentDistance - noteListForPlayback[mid].distance
if (result === 0) break;
if (result < 0) tail = mid - 1;
if (result > 0) head = mid + 1;
}
startOffset = mid
while (startOffset > 0 && noteListForPlayback[startOffset].distance >= currentDistance) startOffset--;
}
// search sub array end
{
let head = 0, tail = noteListForPlayback.length - 1
let mid
while (head <= tail) {
mid = Math.floor((head + tail) / 2)
const result = (currentDistance + renderDistance) - noteListForPlayback[mid].distance
if (result === 0) break;
if (result < 0) tail = mid - 1;
if (result > 0) head = mid + 1;
}
endOffset = mid
while (endOffset < noteListForPlayback.length && noteListForPlayback[endOffset].distance <= currentDistance + renderDistance) endOffset++;
}
return noteListForPlayback
.slice(Math.max(0, startOffset), Math.min(noteListForPlayback.length, endOffset))
.filter(i => i.distance > currentDistance && i.distance < currentDistance + renderDistance)
}
window.getNotesForDraw = getNotesForDraw;
function getHoldsForDraw(currentDistance, currentTs, renderDistance = RENDER_DISTANCE) {
const filteredHoldList = holdList.filter(i => (
(i.distanceMax > currentDistance || i.distanceMin < currentDistance + renderDistance) && i.timeStampEnd > currentTs
))
const drawHoldList = []
filteredHoldList.forEach(hold => {
let offset = 0
let head = 0, tail = 1
const nodeCount = hold.nodes.length
while (offset < nodeCount) {
if (hold.nodes[offset].distance < currentDistance) { // after leave, set head to last one before leaving current render
head = offset
} else if (hold.nodes[offset].distance > currentDistance + renderDistance) { // before enter, set tail to first one before enter render
tail = offset
if (tail-head > 0) drawHoldList.push(hold.nodes.slice(head, tail + 1))
head = offset + 1
tail = offset + 2
} else if (offset === nodeCount - 1) { // last one in chain
tail = offset
if (tail-head > 0) drawHoldList.push(hold.nodes.slice(head, tail + 1))
} else if (offset > 0 && offset < nodeCount - 1) { // not first or last one in chain, if change render direction (due to sfl), break render chain
if (hold.nodes[offset].distance > hold.nodes[offset - 1].distance && hold.nodes[offset].distance > hold.nodes[offset + 1].distance) {
tail = offset
if (tail-head > 0) drawHoldList.push(hold.nodes.slice(head, tail + 1))
head = offset
} else if (hold.nodes[offset].distance < hold.nodes[offset - 1].distance && hold.nodes[offset].distance < hold.nodes[offset + 1].distance) {
tail = offset
if (tail-head > 0) drawHoldList.push(hold.nodes.slice(head, tail + 1))
head = offset
}
}
offset++
}
})
return drawHoldList.filter(i => (
i[i.length - 1].timestamp > currentTs
))
}
const pendingLaneChange = []
const transitionLength = 80
let laneChangeIdx = 0
function updateLaneOnState(fromTs, toTs) {
if (!laneToggleList.length) return
if (fromTs === -1) {
pendingLaneChange.splice(0, pendingLaneChange.length)
for (let i=0; i<60 * laneEffectMul; i++) {
laneOnState[i] = 0
}
laneChangeIdx = 0
}
while (laneChangeIdx < laneToggleList.length) {
let i = laneToggleList[laneChangeIdx]
if (i.timestamp > toTs) break
if (i.tickTotal === 0 || toTs - i.timestamp > transitionLength) {
const value = i.noteType === '12' ? 1 : 0
const width = i.noteWidth * laneEffectMul
for (let idx = 0; idx < width; idx++) {
laneOnState[(i.laneOffset * laneEffectMul + idx) % (60 * laneEffectMul)] = value
}
} else if (pendingLaneChange.indexOf(i) === -1) {
pendingLaneChange.push(i)
}
laneChangeIdx++
}
pendingLaneChange.forEach(i => {
const value = i.noteType === '12' ? 1 : 0
const transitionPercent = Math.min(toTs - i.timestamp, transitionLength) / transitionLength
const width = i.noteWidth * laneEffectMul
if (toTs - i.timestamp > transitionLength) {
for (let idx = 0; idx < width; idx++) {
laneOnState[(i.laneOffset * laneEffectMul + idx) % (60 * laneEffectMul)] = value
}
pendingLaneChange.splice(pendingLaneChange.indexOf(i), 1)
return
}
for (let idx = 0; idx < width; idx++) {
let idxCompare = idx + 0.5
if (i.extParam2 === 0) {
if (idxCompare > width * transitionPercent) continue
} else if (i.extParam2 === 1) {
if (width - idxCompare > width * transitionPercent) continue
} else if (i.extParam2 === 2) {
const transitionBorder = width * transitionPercent / 2
if (value === 1) {
if (Math.abs(width/2 - idxCompare) > transitionBorder) continue
} else {
if (Math.abs(width/2 - idxCompare) < (width / 2 - transitionBorder)) continue
}
}
laneOnState[(i.laneOffset * laneEffectMul + idx) % (60 * laneEffectMul)] = value
}
})
}
bgm.addEventListener('seeked', function (e) {
currentTs = Math.round(this.currentTime * 1000)
if (enableBga) bga.currentTime = this.currentTime
startNextFrame = true
})
bgm.addEventListener('pause', pause)
bgm.addEventListener('play', play)
speed_input.addEventListener('change', e => {
const speed = speed_input.value / 10
speed_val.textContent = speed
RENDER_DISTANCE = 3000 / speed
drawForNextFrame = true
})
function resize() {
const w = Math.round(window.innerWidth * devicePixelRatio), h = Math.round(window.innerHeight * devicePixelRatio)
canvas.width = w
canvas.height = h
maxR = Math.round(Math.min(w, h) * 0.45)
drawForNextFrame = true
if (enableBga) {
const wView = window.innerWidth, hView = window.innerHeight
const centerX = wView / 2, centerY = hView / 2
const rView = Math.round(Math.min(wView, hView) * 0.45)
bga.style.left = (centerX - rView) + 'px'
bga.style.top = (centerY - rView) + 'px'
bga.style.width = (rView * 2) + 'px'
bga.style.height = (rView * 2) + 'px'
bga.style.display = 'block'
} else {
bga.style.display = 'none'
}
createArrows()
}
window.addEventListener('resize', resize)
resize();
//})