MercuryPlayer/main.js

1588 lines
56 KiB
JavaScript

//window.addEventListener('load', function () {
const mpVersion = '1.2.0';
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 bpmList = []
let metList = []
let sflTsList = []
let laneToggleList = []
let idOffsetMap = []
let prevIdMap = {}
let chartHeader = {}
let reverseSection = []
let holdList = []
let seTrigger = []
let pendingSeTrigger = []
let seRTrigger = []
let pendingSeRTrigger = []
let seClockTrigger = []
let pendingSeClockTrigger = []
let comboInfo = [0,0]
const arrowCanvas = {
in: document.createElement('canvas'),
out: document.createElement('canvas'),
}
function pathToArcPoint(ctx, x, y, r, angle) {
ctx.lineTo(
x + Math.cos(angle) * r,
y + Math.sin(angle) * r,
)
}
function createArrows() {
arrowCanvas.in.width = maxR*2, arrowCanvas.in.height = maxR*2
arrowCanvas.out.width = maxR*2, arrowCanvas.out.height = maxR*2
const ctx = {
in: arrowCanvas.in.getContext('2d'),
out: arrowCanvas.out.getContext('2d'),
}
const borderWidth = 12 * displayRatio, colorWidth = 5 * displayRatio
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()
pathToArcPoint(ctx.in, maxR, maxR, maxR * 0.9, (i+0.25) * Math.PI / 15)
pathToArcPoint(ctx.in, maxR, maxR, maxR * 0.8, (i+0.5) * Math.PI / 15)
pathToArcPoint(ctx.in, maxR, maxR, maxR * 0.9, (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()
pathToArcPoint(ctx.out, maxR, maxR, maxR * 0.75, (i+0.25) * Math.PI / 15)
pathToArcPoint(ctx.out, maxR, maxR, maxR * 0.85, (i+0.5) * Math.PI / 15)
pathToArcPoint(ctx.out, maxR, maxR, maxR * 0.75, (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()
}
}
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')
setTimeout(function() {
const playbackId = 'S03-062'
//parseNotesFromFile(`MusicData/${playbackId}/${playbackId}_03.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)
})
const seContext = new AudioContext
let seBuffer = null
let seRBuffer = null
let clkBuffer = null
if (window.location.protocol !== "file:") {
fetch('note.wav').then(r => r.arrayBuffer()).then(r => {
seContext.decodeAudioData(r, buf => {
if (buf) {
seBuffer = buf
} else {
console.error('decode failed')
}
}, e => console.error(e))
})
fetch('rnote.wav').then(r => r.arrayBuffer()).then(r => {
seContext.decodeAudioData(r, buf => {
if (buf) {
seRBuffer = buf
} else {
console.error('decode failed')
}
}, e => console.error(e))
})
fetch('52.hca.wav').then(r => r.arrayBuffer()).then(r => {
seContext.decodeAudioData(r, buf => {
if (buf) {
clkBuffer = buf
} else {
console.error('decode failed')
}
}, e => console.error(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`)
bgmFileName = 'MER_BGM_'+strId.replace('-', '_')
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)
bgmFileName = 'file'
setBgm(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}`
}
})
let bgmBuffer = null
let bgmFileName = 'file'
function setBgm(path) {
bgmCtr.duration = 0
bgmBuffer = null
fetch(path).then(r => r.arrayBuffer()).then(b => {
setBgmFromBuffer(b)
}).catch(e => console.error(path, e))
}
function setBgmFromBuffer(r) {
seContext.decodeAudioData(r, buf => {
if (buf) {
bgmBuffer = buf
bgmCtr.duration = bgmBuffer.duration
} else {
console.error('decode failed')
}
}, e => console.error(e))
}
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 = []
bpmList = []
metList = []
sflTsList = []
idOffsetMap = []
prevIdMap = {}
chartHeader = {}
reverseSection = []
holdList = []
seTrigger = []
seRTrigger = []
seClockTrigger = []
comboInfo = [0,0]
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,
}
if (isNaN(control.section) || isNaN(control.tick)) return
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;
bpmList = controlList.filter(i => i.cmdType === '2')
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:-999999, value1:1, timestamp:-999999})
// 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
let acrossMinusSfl = false
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
}
for (let j = 1; j < holdChain.length - 1; j++) {
if (holdChain[j].distance > holdChain[j-1].distance && holdChain[j].distance > holdChain[j+1].distance) {
acrossMinusSfl = true
break
}
}
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, startDistance = holdChain[startIndex].distance
const endOffset = holdChain[endIndex].laneOffset, endWidth = holdChain[endIndex].noteWidth, endDistance = holdChain[endIndex].distance
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
if (acrossMinusSfl) holdChain[k].distance = (endDistance - startDistance) * (k - startIndex) / segments + startDistance
}
}
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')
// 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 = []
let laneToggleRawList = noteList.filter(i => (i.noteType === '12' || i.noteType === '13'))
laneToggleRawList.forEach(i => {
const value = i.noteType === '12' ? 1 : 0
const startTime = i.timestamp
if (i.extParam2 === 2) {
const width = i.noteWidth / 2
for (let j=0; j<width; j++) {
const stepTime = startTime ? startTime + Math.round(j * 1000 / 60 / 2) : 0
if (value === 1) {
laneToggleList.push({timestamp: stepTime, value, lane: Math.floor(i.laneOffset + width - 0.5 - j) % 60})
laneToggleList.push({timestamp: stepTime, value, lane: Math.floor(i.laneOffset + width + j) % 60})
} else {
laneToggleList.push({timestamp: stepTime, value, lane: Math.floor(i.laneOffset + j) % 60})
laneToggleList.push({timestamp: stepTime, value, lane: Math.floor(i.laneOffset + i.noteWidth - 0.5 - j) % 60})
}
}
} else if (i.extParam2 === 0) {
for (let j=0; j<i.noteWidth; j++) {
const stepTime = startTime ? startTime + Math.round(j * 1000 / 60 / 2) : 0
laneToggleList.push({timestamp: stepTime, value, lane: (i.laneOffset + j) % 60})
}
} else if (i.extParam2 === 1) {
for (let j=0; j<i.noteWidth; j++) {
const stepTime = startTime ? startTime + Math.round(j * 1000 / 60 / 2) : 0
laneToggleList.push({timestamp: stepTime, value, lane: (i.laneOffset + i.noteWidth - 1 - j) % 60})
}
}
})
laneToggleList.sort((a,b) => a.timestamp-b.timestamp)
console.log('parsed notes')
window.noteTypes = {}
noteListForPlayback.forEach(i => {
if (!window.noteTypes[i.noteType]) window.noteTypes[i.noteType] = []
window.noteTypes[i.noteType].push(i)
})
seRTrigger = Object.keys(noteListForPlayback.filter(i=>['20','21','22','23','24','25','26'].indexOf(i.noteType) !== -1).map(i => i.timestamp).reduce((v,i) => (v[Math.round(i)]=1,v), {})).map(i => parseInt(i)).sort((a,b)=>(a-b))
seTrigger = Object.keys(noteListForPlayback.filter(i=>['10','12','13','14','sectionSep'].indexOf(i.noteType) === -1).map(i => i.timestamp).reduce((v,i) => (v[Math.round(i)]=1,v), {})).map(i => parseInt(i)).sort((a,b)=>(a-b))
comboInfo = [
noteListForPlayback.filter(i=>['9','10','12','13','14','25','sectionSep'].indexOf(i.noteType) === -1).length,
noteListForPlayback.filter(i=>['20','21','22','23','24','25','26'].indexOf(i.noteType) !== -1).length
]
{
sections = noteListForPlayback.filter(i=>i.noteType === 'sectionSep')
const start = sections[0].timestamp
const beats = metList[0].value1
const duration = sections[1].timestamp - start
for (let i=0; i<beats; i++) {
seClockTrigger.push(start + Math.round(duration / beats * i))
}
}
if (bgmFileName !== 'file' && chartHeader.MUSIC_FILE_PATH && chartHeader.MUSIC_FILE_PATH !== bgmFileName) {
setBgm('Sound/Bgm/output/'+chartHeader.MUSIC_FILE_PATH+'.m4a')
}
}
const drawCount = {
frame: 0,
actualFrame: 0,
}
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(() => {
if (stats.childNodes.length == 0) stats.appendChild(document.createTextNode(''))
stats.childNodes[0].nodeValue = [
`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 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
let displayRatio = 1
const laneEffectMul = 1
const laneOnState = new Uint8Array(60 * laneEffectMul)
function mainClock(now) {
try {
render(now)
bgmCtr.timerFunc(now)
} catch(e) {console.error(e)}
requestAnimationFrame(mainClock)
}
requestAnimationFrame(mainClock)
function render(now) {
drawCount.frame++
if (!sflTsList.length) return
if (!playing) {
if (!drawForNextFrame) {
return
}
drawForNextFrame = false
}
drawCount.actualFrame++
ctx.clearRect(0, 0, canvas.width, canvas.height)
const cabView = (canvas.width == 1080 && canvas.height == 1920)
const centerX = canvas.width / 2, centerY = cabView ? (canvas.height / 2) - 58 : 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(80,80,80,0.4)'
ctx.fill()
} else {
ctx.fillStyle = 'rgba(128,128,128,0.4)'
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 * displayRatio
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
let reversing = null
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 = Math.max(currentTs, 0)
for (let i=0; i<reverseSection.length; i++) {
if (calcBaseTs > reverseSection[i][0] && calcBaseTs < reverseSection[i][1]) {
reversing = reverseSection[i]
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
}
updateLaneOnState(previousTs, currentTs)
// black out "off" lanes
const laneBgGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, maxR)
laneBgGradient.addColorStop(0, 'rgba(0,0,0,0.3)');
laneBgGradient.addColorStop(0.2, 'rgba(0,0,0,0.4)');
laneBgGradient.addColorStop(1, enableBga ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.7)');
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 * 0.1, drawDistance * 1.1).filter(i => i.timestamp > currentTs - 200)
notesToRender = {
sectionSep: [],
touch: [],
hold: [],
holdBody: [],
chain: [],
flickL: [],
flickR: [],
snapIn: [],
snapOut: [],
arrow: [],
R: [],
laneEffect: [],
sameTime: [],
unknown: []
};
notesToRenderArr.forEach(i => {
switch (i.noteType) {
case 'sectionSep': {if (i.section) 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 * displayRatio
ctx.strokeStyle = '#BBB'
notesToRender.sectionSep.forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = 10 * thicc
const scale = r / maxR
ctx.translate(centerX, centerY)
ctx.scale(scale, scale)
ctx.beginPath()
ctx.arc(
0, 0,
maxR,
0, Math.PI * 2
)
ctx.stroke()
ctx.setTransform(1, 0, 0, 1, 0, 0)
})
}
{
const drawHoldList = getHoldsForDraw(currentDistance, currentTs, drawDistance);
//ctx.fillStyle = 'rgba(207,162,93, 0.7)'
ctx.fillStyle = 'rgba(220,200,90, 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
const gapWidth = nodes[i].noteWidth == 60 ? 0 : 0.01
if (i === 0) {
ctx.arc(
centerX, centerY,
r,
Math.PI * (start / 30) + gapWidth, Math.PI * (end / 30) - gapWidth
)
} else {
pathToArcPoint(ctx, centerX, centerY, r, Math.PI * (end / 30) - gapWidth)
}
}
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
const gapWidth = nodes[i].noteWidth == 60 ? 0 : 0.01
if (i === nodeCount-1) {
ctx.arc(
centerX, centerY,
r,
Math.PI * (end / 30) - gapWidth, Math.PI * (start / 30) + gapWidth,
true
)
} else {
pathToArcPoint(ctx, centerX, centerY, r, Math.PI * (start / 30) - gapWidth)
}
}
ctx.closePath()
ctx.fill()
})
}
// blue extend for same time notes
{
const key = 'sameTime', color = 'rgb(80,255,250)'
const thicc = 4 * displayRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = 10 * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
const scale = r / maxR
ctx.translate(centerX, centerY)
ctx.scale(scale, scale)
ctx.beginPath()
ctx.arc(
0, 0,
maxR,
Math.PI * (start / 30), Math.PI * (end / 30)
)
ctx.stroke()
ctx.setTransform(1, 0, 0, 1, 0, 0)
})
}
}
// white border for R notes
{
const key = 'R', color = 'rgb(255,255,255)'
const thicc = 5 * displayRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = 10 * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
const cutOut = i.noteWidth < 60 ? 0.01 : 0
const scale = r / maxR
ctx.translate(centerX, centerY)
ctx.scale(scale, scale)
ctx.beginPath()
ctx.arc(
0, 0,
maxR,
Math.PI * (start / 30) + cutOut, Math.PI * (end / 30) - cutOut
)
ctx.stroke()
ctx.setTransform(1, 0, 0, 1, 0, 0)
})
}
}
const colorMap = [
['hold', 'rgb(88,75,47)'],
['touch', 'rgb(216,45,184)'],
//['chain', 'rgb(157,122,40)'],
['chain', 'rgb(234,234,170)'],
['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 : 2.5) * displayRatio
if (notesToRender[key].length) {
ctx.strokeStyle = color
notesToRender[key].forEach(i => {
const r = distanceToRenderRadius(maxR, (i.distance - currentDistance) / RENDER_DISTANCE)
ctx.lineWidth = 10 * thicc
const start = 60 - i.laneOffset - i.noteWidth, end = 60 - i.laneOffset
const cutOut = i.noteWidth < 60 ? 0.03 : 0
const scale = r / maxR
ctx.translate(centerX, centerY)
ctx.scale(scale, scale)
ctx.beginPath()
ctx.arc(
0, 0,
maxR,
Math.PI * (start / 30) + cutOut, Math.PI * (end / 30) - cutOut
)
ctx.stroke()
ctx.setTransform(1, 0, 0, 1, 0, 0)
})
}
})
if (notesToRender.arrow.length) {
const flicks = [
[],
[],
]
for (let i=notesToRender.arrow.length-1; i>=0; i--) {
const a = notesToRender.arrow[i]
if (['5','6','23'].indexOf(a.noteType) !== -1) {
flicks[0].push(a)
continue
} else if (['7','8','24'].indexOf(a.noteType) !== -1) {
flicks[1].push(a)
continue
}
const r = distanceToRenderRadius(maxR, (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.leftRotation, centerX-r, centerY-r, r*2, r*2)
break
}
case '7': case '8': case '24': {
ctx.drawImage(arrowCanvas.rightRotation, centerX-r, centerY-r, r*2, r*2)
break
}
}
ctx.restore()
}
const arrowParam = [
[0.00, 0.05, 0.80, 0.90, 0.95, 1.00],
[0.30, 0.35, 1.00, 0.90, 0.50, 0.30],
[0.00, 0.50, 0.80, 0.30, 0.00, 0.00],
]
const arrowTipParam = [
[0.00, 0.05, 0.90, 0.98, 1.00],
[0.30, 0.35, 1.00, 0.30, 0.00],
[0.00, 0.50, 0.90, 0.30, 0.00],
]
const rotateSpeed = 8
const nodes = []
for (let i=0; i<2; i++) {
const arrowAngleDirection = [-0.4, 0.4][i]
const color = ['rgb(246,159,55)', 'rgb(98,251,43)'][i]
flicks[i].forEach(a => {
const r = distanceToRenderRadius(maxR*0.95, (a.distance - currentDistance) / RENDER_DISTANCE)
const start = 60 - a.laneOffset - a.noteWidth, end = 60 - a.laneOffset
const rPos = 1 - (a.distance - currentDistance) / RENDER_DISTANCE
const rotateOffset = ((rPos * rotateSpeed) * [1, -1][i] + 2) % 2
const flickNodes = {n:[], r:r, c: color}
for (let angle = start + rotateOffset; angle < end; angle+=2) {
const sharpness = (angle - start) / (end - start) + 0.5
const coordBase = [
angle + 0.2 + arrowAngleDirection * (i ? 2 - sharpness : sharpness),
angle + 0.2 - arrowAngleDirection * (i ? 2 - sharpness : sharpness),
angle + 0.2 + arrowAngleDirection * (i ? 2 - sharpness : sharpness),
]
const subNodes = {n:[], w:0}
for (let j=0; j<3; j++) {
const useParam = [arrowParam, arrowTipParam, arrowParam][j]
let pos = (coordBase[j] - start) / (end - start)
if (i) pos = 1 - pos
let h = useParam[1][0], w = useParam[2][0]
for (let k=0; k<useParam[0].length; k++) {
if (k === useParam[0].length - 1) {
h = useParam[1][k]
w = useParam[2][k]
break
}
if (pos >= useParam[0][k] && pos < useParam[0][k + 1]) {
h = useParam[1][k] + (useParam[1][k+1] - useParam[1][k]) * ((pos - useParam[0][k]) / (useParam[0][k+1] - useParam[0][k]))
w = useParam[2][k] + (useParam[2][k+1] - useParam[2][k]) * ((pos - useParam[0][k]) / (useParam[0][k+1] - useParam[0][k]))
break
}
}
w *= 0.8 * rPos
subNodes.n.unshift([h * [-1, 0, 1][j], (coordBase[j] - w) * Math.PI / 30])
subNodes.n.push ([h * [-1, 0, 1][j], (coordBase[j] + w) * Math.PI / 30])
if (j == 1) subNodes.w = w
}
flickNodes.n.push(subNodes)
}
nodes.push(flickNodes)
})
}
ctx.strokeStyle = 'rgb(200,200,200)'
nodes.sort((a,b) => a.r - b.r)
nodes.forEach(i => {
//ctx.clearRect(0, 0, canvas.width, canvas.height)
const scale = i.r / maxR
ctx.fillStyle = i.c
ctx.translate(centerX, centerY)
ctx.scale(scale, scale)
i.n.forEach(i => {
ctx.lineWidth = i.w / 0.8 * 4 * displayRatio
ctx.beginPath()
i.n.forEach(i => {
pathToArcPoint(ctx, 0, 0, maxR * (0.875 + 0.075 * i[0]), i[1])
})
ctx.closePath()
ctx.fill()
if (i.w > 0) ctx.stroke()
})
ctx.setTransform(1, 0, 0, 1, 0, 0)
//const r = i.r
//ctx.drawImage(arrowCanvas.flick, 0, 0)
})
}
{
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) * displayRatio
ctx.beginPath()
ctx.arc(
centerX, centerY,
r,
Math.PI * (-0.5 - chartRemaining * 2), -Math.PI * 0.5
)
ctx.stroke()
}
// circle mask
{
ctx.globalCompositeOperation = 'destination-in'
ctx.beginPath();
ctx.arc(centerX, centerY, maxR, 0, Math.PI * 2)
ctx.fillStyle = 'white'
ctx.fill()
ctx.globalCompositeOperation = 'source-over'
}
if (bpmList.length && metList.length) {
const chkTs = Math.round(Math.max(0, currentTs))
const bpmItem = (bpmList.filter(v => v.timestamp <= chkTs).slice(-1)[0])
const bpm = bpmItem.value1
const met = (metList.filter(v => v.timestamp <= chkTs).slice(-1)[0])
const refBase = bpmItem.timestamp > met.timestamp ? bpmItem : met
const beatDuration = 60000 / bpm * 4 / (met.value2 || 4)
const playedNotesCount = [
0,
0
]
for (let i=0; i<noteListForPlayback.length; i++) {
if (noteListForPlayback[i].timestamp > chkTs) break
playedNotesCount[0] += ['9','10','12','13','14','25','sectionSep'].indexOf(noteListForPlayback[i].noteType) === -1
playedNotesCount[1] += ['20','21','22','23','24','25','26'].indexOf(noteListForPlayback[i].noteType) !== -1
}
const playInfoStat = {
min: ''+Math.floor(chkTs / 60000),
sec: ('0'+(Math.floor(chkTs / 1000) % 60)).slice(-2),
milisec: ('000'+(chkTs % 1000)).slice(-3),
section: ('000'+Math.floor((chkTs - refBase.timestamp) / beatDuration / met.value1 + refBase.section)).slice(-3),
beat: Math.floor((chkTs - refBase.timestamp) / beatDuration + refBase.tick / (TICK_PER_GAME_SECTION / met.value1)) % met.value1,
bpm,
sfl
}
const playInfoText = [
`${playedNotesCount[0]} (${playedNotesCount[1]}) / ${comboInfo[0]} (${comboInfo[1]})`,
`${playInfoStat.min}:${playInfoStat.sec}.${playInfoStat.milisec} (${playInfoStat.section}/${playInfoStat.beat})`,
`BPM: ${playInfoStat.bpm}`,
`SFL: ${playInfoStat.sfl}`,
``,
`v${mpVersion}`
]
if (reversing) {
playInfoText.push('Reversing')
}
const fontSize = 16 * devicePixelRatio
ctx.font = `${fontSize}px Arial`
ctx.fillStyle = 'white'
for (let i=0; i<playInfoText.length; i++) {
ctx.fillText(playInfoText[i], centerX - maxR, centerY - maxR + 10 + fontSize * i)
}
}
{
while (pendingSeTrigger.length && pendingSeTrigger[0] - currentTs < 100) {
if (!seBuffer && !clkBuffer) break
if (pendingSeTrigger[0] - currentTs > -25) {
let bufSrc = seContext.createBufferSource()
bufSrc.buffer = seBuffer
bufSrc.connect(gain)
bufSrc.start(seContext.currentTime + Math.max(0, pendingSeTrigger[0] - currentTs) / 1000)
}
pendingSeTrigger.shift()
}
while (pendingSeRTrigger.length && pendingSeRTrigger[0] - currentTs < 100) {
if (!seRBuffer) break
if (pendingSeRTrigger[0] - currentTs > -25) {
let bufSrc = seContext.createBufferSource()
bufSrc.buffer = clkBuffer != null ? clkBuffer : seRBuffer
bufSrc.connect(gain)
bufSrc.start(seContext.currentTime + Math.max(0, pendingSeRTrigger[0] - currentTs) / 1000)
}
pendingSeRTrigger.shift()
}
while (pendingSeClockTrigger.length && pendingSeClockTrigger[0] - currentTs < 100) {
if (!seBuffer && !clkBuffer) break
if (pendingSeClockTrigger[0] - currentTs > -25) {
let bufSrc = seContext.createBufferSource()
bufSrc.buffer = clkBuffer != null ? clkBuffer : seBuffer
bufSrc.connect(gain)
bufSrc.start(seContext.currentTime + Math.max(0, pendingSeClockTrigger[0] - currentTs) / 1000)
}
pendingSeClockTrigger.shift()
}
}
}
let globalPlaybackOffset = 0
// if (/win32/i.test(navigator.platform) && /firefox/i.test(navigator.userAgent)) globalPlaybackOffset = -60
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
}
let bgmBufSrc = null
window.play = function () {
startNextFrame = true
if (bgmBuffer != null && bgmBufSrc == null) {
bgmBufSrc = seContext.createBufferSource()
bgmBufSrc.buffer = bgmBuffer
bgmBufSrc.connect(bgmGain)
bgmBufSrc.start(0, bgmCtr.currentTime)
currentTs = Math.round(bgmCtr.currentTime * 1000) + globalPlaybackOffset
}
bgmCtr.play()
if (enableBga) bga.play()
bga.muted = true
playing = true
pendingSeTrigger = seTrigger.filter(i => i > currentTs)
pendingSeRTrigger = seRTrigger.filter(i => i > currentTs)
pendingSeClockTrigger = seClockTrigger.filter(i => i > currentTs)
seContext.resume()
gain.gain.value = se_volume.value / 100
}
window.pause = function () {
if (bgmBufSrc != null) {
bgmBufSrc.stop()
bgmBufSrc = null
}
bgmCtr.pause()
if (enableBga) bga.pause()
playing = false
gain.gain.value = 0
}
window.stop = function () {
pause()
currentDistance = 0
currentTs = 0
bgmCtr.currentTime = 0
if (enableBga) bga.currentTime = 0
drawForNextFrame = true
sflOffset = 0
sfl = 1
}
window.setPlaybackTime = function (time = 0) {
currentTs = Math.round(time * 1000)
bgmCtr.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
laneOnState[i.lane] = i.value
laneChangeIdx++
}
}
speed_input.addEventListener('input', 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
const cabView = (canvas.width == 1080 && canvas.height == 1920)
maxR = cabView ? 530 : Math.round(Math.min(w, h) * 0.45)
drawForNextFrame = true
displayRatio = Math.max(w, h) / 1920
const wView = window.innerWidth, hView = window.innerHeight
const centerX = wView / 2, centerY = cabView ? (hView / 2) - 58 : hView / 2
const rView = Math.round(Math.min(wView, hView) * 0.45)
if (enableBga) {
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'
}
play_info.style.left = (centerX - rView) + 'px'
play_info.style.top = (centerY - rView) + 'px'
createArrows()
}
window.addEventListener('resize', resize)
resize();
let gain = seContext.createGain()
gain.connect(seContext.destination);
let bgmGain = seContext.createGain()
bgmGain.connect(seContext.destination)
for (let i=0; i<11; i++) {
let option = document.createElement('option')
option.setAttribute('value', i * 10)
option.textContent = i * 10
se_volume.appendChild(option)
}
se_volume.value = 100
se_volume.addEventListener('change', () => {
gain.gain.value = se_volume.value / 100
})
se_file.addEventListener('change', () => {
const reader = new FileReader()
reader.readAsArrayBuffer(se_file.files[0])
reader.onload = e => {
seContext.decodeAudioData(reader.result, buf => {
if (buf) {
seBuffer = buf
} else {
console.error('decode failed')
}
}, e => console.error(e))
}
})
class BgmController {
__playSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"context-fill\" fill-opacity=\"context-fill-opacity\"><path d=\"m2.992 13.498 0-10.996a1.5 1.5 0 0 1 2.245-1.303l9.621 5.498a1.5 1.5 0 0 1 0 2.605L5.237 14.8a1.5 1.5 0 0 1-2.245-1.302z\"/></svg>"
__pauseSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"context-fill\" fill-opacity=\"context-fill-opacity\"><path d=\"m4.5 14-1 0A1.5 1.5 0 0 1 2 12.5l0-9A1.5 1.5 0 0 1 3.5 2l1 0A1.5 1.5 0 0 1 6 3.5l0 9A1.5 1.5 0 0 1 4.5 14z\"/><path d=\"m11.5 14-1 0A1.5 1.5 0 0 1 9 12.5l0-9A1.5 1.5 0 0 1 10.5 2l1 0A1.5 1.5 0 0 1 13 3.5l0 9a1.5 1.5 0 0 1-1.5 1.5z\"/></svg>"
__volumeSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"context-fill\" fill-opacity=\"context-fill-opacity\"><path d=\"M7.245 1.35 4.117 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14L9 2c0-.927-1.152-1.353-1.755-.65z\"/><path d=\"M11.764 15a.623.623 0 0 1-.32-1.162 6.783 6.783 0 0 0 3.306-5.805 6.767 6.767 0 0 0-3.409-5.864.624.624 0 1 1 .619-1.085A8.015 8.015 0 0 1 16 8.033a8.038 8.038 0 0 1-3.918 6.879c-.1.06-.21.088-.318.088z\"/><path d=\"M11.434 11.85A4.982 4.982 0 0 0 13.25 8a4.982 4.982 0 0 0-1.819-3.852l-.431 0 0 7.702.434 0z\"/></svg>"
__volumeMutedSvg = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 16\" width=\"16\" height=\"16\" fill=\"context-fill\" fill-opacity=\"context-fill-opacity\"><path d=\"m11 4.149 0 4.181 1.775 1.775c.3-.641.475-1.35.475-2.105a4.981 4.981 0 0 0-1.818-3.851l-.432 0z\"/><path d=\"M2.067 1.183a.626.626 0 0 0-.885.885L4.115 5 2 5a2 2 0 0 0-2 2l0 2a2 2 0 0 0 2 2l2.117 0 3.128 3.65C7.848 15.353 9 14.927 9 14l0-4.116 3.317 3.317c-.273.232-.56.45-.873.636a.624.624 0 0 0-.218.856.621.621 0 0 0 .856.219 7.58 7.58 0 0 0 1.122-.823l.729.729a.626.626 0 0 0 .884-.886L2.067 1.183z\"/><path d=\"M9 2c0-.926-1.152-1.352-1.755-.649L5.757 3.087 9 6.33 9 2z\"/><path d=\"M11.341 2.169a6.767 6.767 0 0 1 3.409 5.864 6.732 6.732 0 0 1-.83 3.217l.912.912A7.992 7.992 0 0 0 16 8.033a8.018 8.018 0 0 0-4.04-6.95.625.625 0 0 0-.619 1.086z\"/></svg>"
_element = null
_playBtn = null
_progressInput = null
_playedText = null
_durationText = null
_volumeBtn = null
_volumeInput = null
_currentTime = 0
_duration = 0
_playing = false
_volume = 1
_muted = false
_pendingSeek = false
/**
* @param {HTMLElement} e
*/
constructor(e) {
this._element = e
// while (e.childNodes.length) e.childNodes[0].remove()
e.innerHTML = '<div class="btn"></div><div class="progress"><input type="range" step="any" min="0" max="1" value="0"/></div><div class="duration"><span class="played">0:00</span><span class="duration-grey"> / <span class="total">0:00</span></span></div><div class="volume"></div><div class="volume_slider"><input type="range" step="any" min="0" max="1" value="1"/></div>'
this._playBtn = e.getElementsByClassName('btn')[0]
this._progressInput = e.getElementsByClassName('progress')[0].children[0]
this._playedText = e.getElementsByClassName('played')[0].childNodes[0]
this._durationText = e.getElementsByClassName('total')[0].childNodes[0]
this._volumeBtn = e.getElementsByClassName('volume')[0]
this._volumeInput = e.getElementsByClassName('volume_slider')[0].children[0]
this.setPlayBtn(false)
this.setVolumeBtn(false)
this.setListeners()
}
setPlayBtn(isPause) {
if (isPause) {
this._playBtn.innerHTML = this.__pauseSvg
} else {
this._playBtn.innerHTML = this.__playSvg
}
}
setVolumeBtn(isMute) {
if (isMute) {
this._volumeBtn.innerHTML = this.__volumeMutedSvg
} else {
this._volumeBtn.innerHTML = this.__volumeSvg
}
}
play() {
if (this._playing) return
if (this._currentTime >= this._duration) {
this._currentTime = 0
}
this._playing = true
this.setPlayBtn(this._playing)
this.dispatchEvent('play')
}
pause() {
if (!this._playing) return
this._playing = false
this.setPlayBtn(this._playing)
this.dispatchEvent('pause')
}
get volume() {
if (this._muted) return 0
return this._volume
}
/**
* @param {number} v
*/
set volume(v) {
if (typeof v !== 'number' || isNaN(v)) return
v = Math.max(0, v)
v = Math.min(1, v)
this.dispatchEvent('volumeChange', v)
this._volumeInput.value = v
if (v === 0) {
this._muted = true
this.setVolumeBtn(this._muted)
} else {
this._muted = false
this._volume = v
this.setVolumeBtn(this._muted)
this.dispatchEvent('volumeChange', this._volume)
}
}
get duration() {
return this._duration
}
set duration(v) {
this._duration = Math.max(v, 0)
this.updateTotalTime(this._duration)
}
get currentTime() {
return this._currentTime
}
set currentTime(v) {
this._currentTime = Math.min(v, this._duration)
this.updateCurrentTime(this._currentTime)
}
_lastNow = null
timerFunc(now) {
const elapsed = now - this._lastNow
this._lastNow = now
if (!this._playing) return
if (this._duration <= 0) return
this._currentTime += elapsed / 1000
if (this._currentTime >= this._duration) {
this._currentTime = this._duration
this.pause()
}
if (!this._pendingSeek) {
this.updateCurrentTime(this._currentTime)
}
}
_timerFuncBound = null
setListeners() {
this._playBtn.addEventListener('click', this.playBtnClick.bind(this))
this._volumeBtn.addEventListener('click', this.volumeBtnClick.bind(this))
this._progressInput.addEventListener('input', this.progressInput.bind(this))
this._progressInput.addEventListener('change', this.progressChange.bind(this))
this._volumeInput.addEventListener('input', this.volumeInput.bind(this))
this._volumeInput.addEventListener('change', this.volumeChange.bind(this))
this._lastNow = performance.now()
}
playBtnClick(e) {
if (this._playing) { // pause
this.pause()
} else { // play
this.play()
}
}
volumeBtnClick(e) {
if (this._muted) { // unmute
this.volume = this._volume
} else { // mute
this.volume = 0
}
}
progressInput(e) {
if (this._duration <= 0) {
e.target.value = 0
return
}
this._pendingSeek = true
this.updateCurrentTime(e.target.value * this._duration)
}
progressChange(e) {
if (this._duration <= 0) {
e.target.value = 0
return
}
this._pendingSeek = false
this._currentTime = e.target.value * this._duration
this.updateCurrentTime(this._currentTime)
this.dispatchEvent('seeked', this._currentTime)
}
_lastChangeVolume = 1
volumeInput(e) {
this.volume = parseFloat(this._volumeInput.value)
}
volumeChange(e) {
const v = parseFloat(this._volumeInput.value)
this.volume = this._lastChangeVolume
this.volume = v
if (v > 0) {
this._lastChangeVolume = v
}
}
formatTime(sec) {
const m = Math.floor(sec / 60)
const s = ('0'+Math.floor(sec % 60)).slice(-2)
return m+':'+s
}
updateCurrentTime(sec) {
this._playedText.nodeValue = this.formatTime(sec)
if (this._duration > 0) {
this._progressInput.value = sec / this._duration
}
}
updateTotalTime(dur) {
this._durationText.nodeValue = this.formatTime(dur)
}
eventListeners = {
play: [],
pause: [],
volumeChange: [],
seeked: [],
}
dispatchEvent(eventName, data) {
this.eventListeners[eventName].forEach(i => i(data))
}
addEventListener(eventName, listener) {
if (this.eventListeners[eventName] === undefined) {
throw new Error('Unsupported event ' + eventName)
}
this.eventListeners[eventName].push(listener)
}
}
const bgmCtr = new BgmController(bgm_custom)
bgmCtr.addEventListener('seeked', function (e) {
currentTs = Math.round(bgmCtr.currentTime * 1000)
if (enableBga) bga.currentTime = bgmCtr.currentTime
pause()
if (bgmCtr.currentTime < bgmCtr.duration) play()
})
bgmCtr.addEventListener('pause', pause)
bgmCtr.addEventListener('play', play)
bgmCtr.addEventListener('volumeChange', v => {
bgmGain.gain.value = v
})
document.title += ` v${mpVersion}`
//})