이렇게 나옵니다
시간 입력시 하단에 어디 구간이 골드 효율 가장 높은지 나와요
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>스테이지 초당 골드 계산기</title>
<style>
:root{
--bg:#0b0f1a;
--panel:#141b2b;
--panel2:#1a2338;
--border:#293349;
--border-strong:#3a4560;
--gold:#f0b132;
--gold-dim:#c98f22;
--green:#3ecf6e;
--green-bg:rgba(62,207,110,0.12);
--text:#e8e9ee;
--text-dim:#8992a8;
--danger:#e2534a;
--chaos:#b06bff;
--chaos-bg:rgba(176,107,255,0.12);
}
*{box-sizing:border-box;}
body{
margin:0;
background:radial-gradient(circle at 50% 0%, #131c30 0%, var(--bg) 60%);
color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Malgun Gothic",sans-serif;
padding:16px 10px 60px;
}
.wrap{max-width:820px;margin:0 auto;}
h1{
font-size:19px;
font-weight:700;
margin:0 0 4px;
color:var(--gold);
letter-spacing:0.2px;
text-shadow:0 0 12px rgba(240,177,50,0.35);
}
.sub{color:var(--text-dim);font-size:12px;margin:0 0 16px;}
.mode-tabs{
display:flex;
gap:8px;
margin-bottom:14px;
}
.mode-tab{
flex:1;
padding:10px;
text-align:center;
border-radius:10px;
border:1px solid var(--border);
background:var(--panel);
color:var(--text-dim);
font-weight:700;
font-size:14px;
cursor:pointer;
transition:all .15s;
}
.mode-tab.active[data-mode="normal"]{
color:var(--gold);
border-color:var(--gold-dim);
background:linear-gradient(180deg, rgba(240,177,50,0.15), var(--panel));
box-shadow:0 0 14px rgba(240,177,50,0.15) inset;
}
.mode-tab.active[data-mode="chaos"]{
color:var(--chaos);
border-color:var(--chaos);
background:linear-gradient(180deg, rgba(176,107,255,0.15), var(--panel));
box-shadow:0 0 14px rgba(176,107,255,0.2) inset;
}
.panel{display:none;}
.panel.active{display:block;}
.card{
background:var(--panel);
border:1px solid var(--border);
border-radius:12px;
padding:14px;
margin-bottom:14px;
}
.card h2{font-size:13px;font-weight:600;color:var(--text-dim);margin:0 0 10px;letter-spacing:0.3px;}
.toprow{
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
}
.toprow label{font-size:13px;color:var(--text-dim);white-space:nowrap;}
.toprow input{
width:80px;
background:var(--panel2);
border:1px solid var(--border);
border-radius:8px;
color:var(--gold);
font-size:16px;
font-weight:700;
padding:8px 10px;
text-align:center;
}
.toprow input:focus{outline:none;border-color:var(--gold-dim);}
.toprow .pct{color:var(--text-dim);font-size:13px;}
.save-note{font-size:11px;color:var(--text-dim);margin-left:auto;transition:color .2s;}
.save-note.visible{color:var(--green);}
.btnrow{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap;}
.btn{
background:var(--panel2);
border:1px solid var(--border-strong);
color:var(--text);
border-radius:8px;
padding:7px 12px;
font-size:12px;
cursor:pointer;
white-space:nowrap;
}
.btn:hover{border-color:var(--gold-dim);color:var(--gold);}
.btn.danger:hover{border-color:var(--danger);color:var(--danger);}
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;}
table{width:100%;border-collapse:collapse;font-size:12.5px;}
thead th{
text-align:center;color:var(--text-dim);font-weight:600;font-size:11px;
padding:6px 4px;border-bottom:1px solid var(--border-strong);white-space:nowrap;
}
tbody td{padding:5px 4px;text-align:center;border-bottom:1px solid var(--border);white-space:nowrap;}
tbody tr.best td{background:var(--green-bg);}
tbody tr.best td:first-child{border-left:3px solid var(--green);}
.stage-input,.time-input,.base-input{
width:100%;min-width:50px;background:var(--panel2);border:1px solid var(--border);
border-radius:6px;color:var(--text);font-size:12.5px;padding:6px 4px;text-align:center;
}
.stage-input{min-width:58px;}
.stage-input:focus,.time-input:focus,.base-input:focus{outline:none;border-color:var(--gold-dim);}
.gps{color:var(--gold);font-weight:700;font-size:13.5px;}
tr.best .gps{color:var(--green);}
.del-btn{background:none;border:none;color:var(--text-dim);font-size:15px;cursor:pointer;padding:2px 6px;line-height:1;}
.del-btn:hover{color:var(--danger);}
.add-btn{
width:100%;margin-top:10px;background:var(--panel2);border:1px dashed var(--border-strong);
color:var(--gold);border-radius:8px;padding:10px;font-size:13px;cursor:pointer;
}
.add-btn:hover{border-color:var(--gold-dim);}
.best-summary{
margin-top:0;padding:12px 14px;background:var(--green-bg);
border:1px solid rgba(62,207,110,0.35);border-radius:10px;font-size:13px;
}
.best-summary b{color:var(--green);font-size:15px;}
.chart-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
.chart-rank{width:20px;font-size:11px;color:var(--text-dim);text-align:right;flex-shrink:0;}
.chart-label{width:56px;font-size:12px;color:var(--text);flex-shrink:0;}
.chart-bar-bg{flex:1;background:var(--panel2);border-radius:6px;height:20px;overflow:hidden;position:relative;}
.chart-bar-fill{height:100%;border-radius:6px;background:linear-gradient(90deg, var(--gold-dim), var(--gold));}
.chart-bar-fill.top{background:linear-gradient(90deg, #1f8f4a, var(--green));}
.chart-val{width:78px;font-size:11.5px;color:var(--text-dim);text-align:right;flex-shrink:0;}
.crown{color:#ffd85e;margin-right:2px;}
.formula{font-size:11.5px;color:var(--text-dim);line-height:1.7;margin-top:10px;}
.formula code{color:var(--gold-dim);}
</style>
</head>
<body>
<div class="wrap">
<h1>스테이지 초당 골드 계산기</h1>
<p class="sub">일반 몬스터 60마리 + 보스(적용골드×20) 기준 · 일반 / 카오스 모드 지원</p>
<div class="mode-tabs">
<div class="mode-tab active" data-mode="normal" id="tabNormal">일반 모드</div>
<div class="mode-tab" data-mode="chaos" id="tabChaos">카오스 모드</div>
</div>
<div id="panelNormal" class="panel active"></div>
<div id="panelChaos" class="panel"></div>
</div>
<script>
const PANEL_TEMPLATE = (mode) => `
<div class="card">
<div class="toprow">
<label>골드 획득량</label>
<input type="text" inputmode="decimal" id="rate-${mode}" style="width:70px;">
<span class="pct">%</span>
<label style="margin-left:6px;">몬스터 마리수</label>
<input type="text" inputmode="decimal" id="monsterCount-${mode}" style="width:60px;">
<label style="margin-left:6px;">보스 마리수</label>
<input type="text" inputmode="decimal" id="bossCount-${mode}" style="width:60px;">
<span class="save-note" id="saveNote-${mode}">저장됨</span>
<div class="btnrow">
<button class="btn" id="saveBtn-${mode}">저장</button>
<button class="btn" id="csvBtn-${mode}">CSV 내보내기</button>
<button class="btn danger" id="resetBtn-${mode}">초기화</button>
</div>
</div>
</div>
<div class="card">
<div class="table-scroll">
<table>
<thead>
<tr>
<th>스테이지</th><th>기본골드</th><th>클리어 시간</th><th>총 골드</th><th>초당 골드</th><th>30분당</th><th>1시간당</th><th></th>
</tr>
</thead>
<tbody id="rows-${mode}"></tbody>
</table>
</div>
<button class="add-btn" id="addBtn-${mode}">+ 스테이지 추가</button>
<div class="formula">
적용골드 = 기본골드 × (1 + 획득량%) · 총골드 = (적용골드 × 몬스터 마리수) + (적용골드 × 20 × 보스 마리수) · 초당골드 = 총골드 ÷ 클리어시간(초) · 시간당골드 = 초당골드 × 3600<br>
몬스터/보스 마리수는 스테이지별이 아닌 모드 전체 공통값이며 0으로도 설정할 수 있어요. 시간 입력은 <code>1:33</code> 또는 <code>93</code> 형식 모두 가능
</div>
</div>
<div class="best-summary" id="bestSummary-${mode}"></div>
<div class="card">
<h2>효율 순위 (초당 골드 기준)</h2>
<div id="chart-${mode}"></div>
</div>
`;
document.getElementById('panelNormal').innerHTML = PANEL_TEMPLATE('normal');
document.getElementById('panelChaos').innerHTML = PANEL_TEMPLATE('chaos');
function buildDefaultStages(){
const stages = [];
const areaBase = { 1:[10,30,50,70,90,110,130,150,170], 2:[400,430,460,490,520,550,580,610,640] };
for(let area=1; area<=3; area++){
for(let n=1; n<=9; n++){
const base = (areaBase[area] && areaBase[area][n-1] !== undefined) ? areaBase[area][n-1] : 0;
let time = '';
if(area===1 && n===7) time='1:33';
if(area===1 && n===8) time='2:18';
if(area===1 && n===9) time='2:56';
stages.push({ stage:`${area}-${n}`, base, time });
}
}
return stages;
}
const MODES = {
normal: {
rateKey:'gold-calc-normal-rate-v2',
stagesKey:'gold-calc-normal-stages-v2',
countsKey:'gold-calc-normal-counts-v2',
defaultRate: 15,
defaultMonsterCount: 60,
defaultBossCount: 1,
defaultStages: buildDefaultStages()
},
chaos: {
rateKey:'gold-calc-chaos-rate-v2',
stagesKey:'gold-calc-chaos-stages-v2',
countsKey:'gold-calc-chaos-counts-v2',
defaultRate: 15,
defaultMonsterCount: 60,
defaultBossCount: 1,
defaultStages: buildDefaultStages().map(s => ({ stage:s.stage, base:0, time:'' }))
}
};
const state = {
normal: { rate: MODES.normal.defaultRate, monsterCount: MODES.normal.defaultMonsterCount, bossCount: MODES.normal.defaultBossCount, stages: JSON.parse(JSON.stringify(MODES.normal.defaultStages)) },
chaos: { rate: MODES.chaos.defaultRate, monsterCount: MODES.chaos.defaultMonsterCount, bossCount: MODES.chaos.defaultBossCount, stages: JSON.parse(JSON.stringify(MODES.chaos.defaultStages)) }
};
const saveTimers = {};
function parseTime(str){
if(str === null || str === undefined) return 0;
str = String(str).trim();
if(str === '') return 0;
if(str.includes(':')){
const parts = str.split(':').map(p => parseFloat(p) || 0);
if(parts.length === 2) return parts[0]*60 + parts[1];
if(parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
return 0;
}
const n = parseFloat(str);
return isNaN(n) ? 0 : n;
}
function formatNum(n){ return Math.round(n).toLocaleString('ko-KR'); }
function computeRow(row, rate, monsterCount, bossCount){
const base = parseFloat(row.base) || 0;
const seconds = parseTime(row.time);
const applied = base * (1 + rate/100);
const mCount = (typeof monsterCount === 'number' && !isNaN(monsterCount)) ? monsterCount : 60;
const bCount = (typeof bossCount === 'number' && !isNaN(bossCount)) ? bossCount : 1;
const total = applied*mCount + applied*20*bCount;
const gps = seconds > 0 ? total/seconds : 0;
const gph = gps*3600;
const gp30 = gps*1800;
return { applied, total, seconds, gps, gph, gp30 };
}
const storageAPI = {
async get(key){
if(typeof window.storage !== 'undefined' && window.storage){
try{ return await window.storage.get(key, false); }
catch(e){ /* fall through to localStorage */ }
}
try{
const v = localStorage.getItem(key);
return v !== null ? { key, value: v, shared:false } : null;
}catch(e){ return null; }
},
async set(key, value){
if(typeof window.storage !== 'undefined' && window.storage){
try{ return await window.storage.set(key, value, false); }
catch(e){ /* fall through to localStorage */ }
}
try{
localStorage.setItem(key, value);
return { key, value, shared:false };
}catch(e){ return null; }
}
};
const rowRefs = { normal: [], chaos: [] };
function readCounts(mode){
const m = state[mode];
const monsterInput = document.getElementById(`monsterCount-${mode}`);
const bossInput = document.getElementById(`bossCount-${mode}`);
const mVal = parseFloat(monsterInput.value);
const bVal = parseFloat(bossInput.value);
m.monsterCount = isNaN(mVal) ? 0 : mVal;
m.bossCount = isNaN(bVal) ? 0 : bVal;
return m;
}
function render(mode){
// Full rebuild: only call when rows are added/removed/reset/loaded.
const m = state[mode];
const rateInput = document.getElementById(`rate-${mode}`);
m.rate = parseFloat(rateInput.value) || 0;
readCounts(mode);
const tbody = document.getElementById(`rows-${mode}`);
tbody.innerHTML = '';
rowRefs[mode] = [];
m.stages.forEach((row, i) => {
const tr = document.createElement('tr');
const tdStage = document.createElement('td');
const stageInput = document.createElement('input');
stageInput.className = 'stage-input';
stageInput.value = row.stage;
stageInput.addEventListener('input', e => { m.stages[i].stage = e.target.value; scheduleSave(mode); renderSummaryAndChart(mode); });
tdStage.appendChild(stageInput);
const tdBase = document.createElement('td');
const baseInput = document.createElement('input');
baseInput.className = 'base-input';
baseInput.type = 'text';
baseInput.inputMode = 'decimal';
baseInput.value = row.base;
baseInput.addEventListener('input', e => { m.stages[i].base = e.target.value; updateCalcs(mode); });
tdBase.appendChild(baseInput);
const tdTime = document.createElement('td');
const timeInput = document.createElement('input');
timeInput.className = 'time-input';
timeInput.placeholder = '1:33';
timeInput.value = row.time;
timeInput.addEventListener('input', e => { m.stages[i].time = e.target.value; updateCalcs(mode); });
tdTime.appendChild(timeInput);
const tdTotal = document.createElement('td');
const tdGps = document.createElement('td');
tdGps.className = 'gps';
const tdGp30 = document.createElement('td');
const tdGph = document.createElement('td');
const tdDel = document.createElement('td');
const delBtn = document.createElement('button');
delBtn.className = 'del-btn';
delBtn.textContent = '✕';
delBtn.addEventListener('click', () => { m.stages.splice(i,1); render(mode); scheduleSave(mode); });
tdDel.appendChild(delBtn);
tr.appendChild(tdStage); tr.appendChild(tdBase); tr.appendChild(tdTime);
tr.appendChild(tdTotal); tr.appendChild(tdGps); tr.appendChild(tdGp30); tr.appendChild(tdGph); tr.appendChild(tdDel);
tbody.appendChild(tr);
rowRefs[mode].push({ tr, tdTotal, tdGps, tdGp30, tdGph });
});
updateCalcs(mode);
}
function updateCalcs(mode){
// Recompute and update only the calculated cells + best-row highlight.
// Never touches the stage/base/time <input> elements, so focus and cursor
// position are preserved while typing.
const m = state[mode];
const rateInput = document.getElementById(`rate-${mode}`);
m.rate = parseFloat(rateInput.value) || 0;
readCounts(mode);
const computed = m.stages.map(row => computeRow(row, m.rate, m.monsterCount, m.bossCount));
let bestIdx = -1, bestGps = -1;
computed.forEach((calc,i) => { if(calc.seconds > 0 && calc.gps > bestGps){ bestGps = calc.gps; bestIdx = i; } });
rowRefs[mode].forEach((refs, i) => {
const calc = computed[i];
refs.tr.className = (i === bestIdx) ? 'best' : '';
refs.tdTotal.textContent = formatNum(calc.total) + 'G';
refs.tdGps.textContent = calc.seconds > 0 ? formatNum(calc.gps) + '/s' : '-';
refs.tdGp30.textContent = calc.seconds > 0 ? formatNum(calc.gp30) + 'G' : '-';
refs.tdGph.textContent = calc.seconds > 0 ? formatNum(calc.gph) + 'G' : '-';
});
renderSummaryAndChart(mode);
scheduleSave(mode);
}
function renderSummaryAndChart(mode){
const m = state[mode];
const computed = m.stages.map(row => ({ row, calc: computeRow(row, m.rate, m.monsterCount, m.bossCount) }))
.filter(item => item.calc.seconds > 0)
.sort((a,b) => b.calc.gps - a.calc.gps);
const summary = document.getElementById(`bestSummary-${mode}`);
if(computed.length > 0){
const b = computed[0];
summary.innerHTML = `가장 효율 좋은 스테이지: <b>${b.row.stage}</b> — 초당 ${formatNum(b.calc.gps)}G · 시간당 ${formatNum(b.calc.gph)}G (총 ${formatNum(b.calc.total)}G / ${formatNum(b.calc.seconds)}초)`;
} else {
summary.innerHTML = '클리어 시간을 입력하면 추천 스테이지가 표시됩니다.';
}
const chart = document.getElementById(`chart-${mode}`);
chart.innerHTML = '';
if(computed.length === 0){
chart.innerHTML = '<div style="color:var(--text-dim);font-size:12px;">시간이 입력된 스테이지가 없습니다.</div>';
return;
}
const maxGps = computed[0].calc.gps;
computed.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'chart-row';
const pct = maxGps > 0 ? (item.calc.gps/maxGps*100) : 0;
row.innerHTML = `
<div class="chart-rank">${i===0 ? '<span class="crown">👑</span>' : (i+1)}</div>
<div class="chart-label">${item.row.stage}</div>
<div class="chart-bar-bg"><div class="chart-bar-fill${i===0?' top':''}" style="width:${pct}%"></div></div>
<div class="chart-val">${formatNum(item.calc.gps)}/s</div>
`;
chart.appendChild(row);
});
}
function scheduleSave(mode){
clearTimeout(saveTimers[mode]);
saveTimers[mode] = setTimeout(() => saveState(mode), 500);
}
async function saveState(mode, manual){
const saveBtn = document.getElementById(`saveBtn-${mode}`);
try{
await storageAPI.set(MODES[mode].stagesKey, JSON.stringify(state[mode].stages));
await storageAPI.set(MODES[mode].rateKey, JSON.stringify(state[mode].rate));
await storageAPI.set(MODES[mode].countsKey, JSON.stringify({ monsterCount: state[mode].monsterCount, bossCount: state[mode].bossCount }));
const note = document.getElementById(`saveNote-${mode}`);
note.textContent = '저장됨';
note.classList.add('visible');
setTimeout(() => note.classList.remove('visible'), 1200);
if(manual){
const original = saveBtn.textContent;
saveBtn.textContent = '저장됨 ✓';
saveBtn.disabled = true;
setTimeout(() => { saveBtn.textContent = original; saveBtn.disabled = false; }, 1000);
}
}catch(err){
console.error('save failed', err);
if(manual){
const original = saveBtn.textContent;
saveBtn.textContent = '저장 실패';
setTimeout(() => { saveBtn.textContent = original; }, 1500);
}
}
}
async function loadState(mode){
try{
const savedStages = await storageAPI.get(MODES[mode].stagesKey);
if(savedStages && savedStages.value) state[mode].stages = JSON.parse(savedStages.value);
}catch(err){}
try{
const savedRate = await storageAPI.get(MODES[mode].rateKey);
if(savedRate && savedRate.value) state[mode].rate = JSON.parse(savedRate.value);
}catch(err){}
try{
const savedCounts = await storageAPI.get(MODES[mode].countsKey);
if(savedCounts && savedCounts.value){
const parsed = JSON.parse(savedCounts.value);
state[mode].monsterCount = parsed.monsterCount;
state[mode].bossCount = parsed.bossCount;
}
}catch(err){}
document.getElementById(`rate-${mode}`).value = state[mode].rate;
document.getElementById(`monsterCount-${mode}`).value = state[mode].monsterCount;
document.getElementById(`bossCount-${mode}`).value = state[mode].bossCount;
render(mode);
}
function exportCSV(mode){
const m = state[mode];
const rows = m.stages.map(row => {
const c = computeRow(row, m.rate, m.monsterCount, m.bossCount);
return [row.stage, row.base, row.time, Math.round(c.applied), Math.round(c.total), c.seconds, Math.round(c.gps), Math.round(c.gp30), Math.round(c.gph)];
});
const header = ['스테이지','기본골드','클리어시간(초)','적용골드','총골드','시간(초)','초당골드','30분당골드','시간당골드'];
const meta = [`몬스터 마리수,${m.monsterCount}`, `보스 마리수,${m.bossCount}`, `골드 획득량(%),${m.rate}`, ''];
const csvLines = [...meta, header.join(','), ...rows.map(r => r.join(','))];
const csv = 'uFEFF' + csvLines.join('n');
const blob = new Blob([csv], { type:'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${mode}_gold_stages.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function resetMode(mode){
state[mode].rate = MODES[mode].defaultRate;
state[mode].monsterCount = MODES[mode].defaultMonsterCount;
state[mode].bossCount = MODES[mode].defaultBossCount;
state[mode].stages = JSON.parse(JSON.stringify(MODES[mode].defaultStages));
document.getElementById(`rate-${mode}`).value = state[mode].rate;
document.getElementById(`monsterCount-${mode}`).value = state[mode].monsterCount;
document.getElementById(`bossCount-${mode}`).value = state[mode].bossCount;
render(mode);
scheduleSave(mode);
}
['normal','chaos'].forEach(mode => {
document.getElementById(`rate-${mode}`).addEventListener('input', () => updateCalcs(mode));
document.getElementById(`monsterCount-${mode}`).addEventListener('input', () => updateCalcs(mode));
document.getElementById(`bossCount-${mode}`).addEventListener('input', () => updateCalcs(mode));
document.getElementById(`addBtn-${mode}`).addEventListener('click', () => {
state[mode].stages.push({ stage:'새 스테이지', base:0, time:'' });
render(mode);
});
document.getElementById(`csvBtn-${mode}`).addEventListener('click', () => exportCSV(mode));
document.getElementById(`saveBtn-${mode}`).addEventListener('click', () => {
clearTimeout(saveTimers[mode]);
saveState(mode, true);
});
document.getElementById(`resetBtn-${mode}`).addEventListener('click', () => {
if(confirm('이 모드의 모든 값을 초기 상태로 되돌릴까요?')) resetMode(mode);
});
loadState(mode);
});
document.getElementById('tabNormal').addEventListener('click', () => switchTab('normal'));
document.getElementById('tabChaos').addEventListener('click', () => switchTab('chaos'));
function switchTab(mode){
document.getElementById('tabNormal').classList.toggle('active', mode==='normal');
document.getElementById('tabChaos').classList.toggle('active', mode==='chaos');
document.getElementById('panelNormal').classList.toggle('active', mode==='normal');
document.getElementById('panelChaos').classList.toggle('active', mode==='chaos');
}
</script>
</body>
</html>
열심히 만들었는데 사용하고 후기 알려주시면 감사하겠습니다ㅏㅏㅏ