Battle Sample
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:sans-serif;background:#1a1a2e;color:#eee;user-select:none;min-height:100vh}
#app{display:flex;flex-direction:column;align-items:center;padding:12px;gap:10px}
h1{font-size:18px;font-weight:500;color:#c9a96e;letter-spacing:2px}
#top{display:flex;gap:16px;align-items:flex-start;width:100%;max-width:720px}
#map-wrap{position:relative}
canvas{display:block;border:2px solid #444;border-radius:4px}
#sidebar{flex:1;min-width:180px;display:flex;flex-direction:column;gap:8px}
.panel{background:#16213e;border:1px solid #2a3a5e;border-radius:6px;padding:10px;font-size:13px}
.panel h3{font-size:12px;color:#c9a96e;margin-bottom:6px;text-transform:uppercase;letter-spacing:1px}
#unit-name{font-size:15px;font-weight:500;margin-bottom:4px}
.bar-row{display:flex;align-items:center;gap:6px;margin:3px 0}
.bar-label{width:28px;font-size:11px;color:#aaa}
.bar-bg{flex:1;height:8px;background:#2a2a3e;border-radius:4px;overflow:hidden}
.bar-fill{height:100%;border-radius:4px;transition:width .3s}
.hp-bar{background:#4caf50}
.mp-bar{background:#5599ff}
#log{height:120px;overflow-y:auto;font-size:12px;line-height:1.6;color:#bbb}
#log div{padding:1px 0;border-bottom:1px solid #1e2a40}
#bottom{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
button{padding:6px 14px;background:#1e3a5f;border:1px solid #3a5a8f;color:#c9d8f0;border-radius:4px;cursor:pointer;font-size:13px;transition:background .15s}
button:hover{background:#2a4a7f}
button:disabled{opacity:.4;cursor:default}
button.danger{background:#5f1e1e;border-color:#8f3a3a;color:#f0c9c9}
button.danger:hover{background:#7f2a2a}
#phase{font-size:13px;color:#c9a96e;text-align:center}
#weapon-info{display:grid;grid-template-columns:1fr 1fr;gap:4px}
.wi{font-size:11px;padding:3px 5px;background:#0d1626;border-radius:3px;border-left:3px solid #3a5a8f}
.wi b{color:#c9a96e}
</style>
</head>
<body>
<div id="app">
<h1>⚔ SRPG Simulator</h1>
<div id="top">
<div id="map-wrap"><canvas id="c" width="400" height="400"></canvas></div>
<div id="sidebar">
<div class="panel" id="info-panel">
<h3>Selected unit</h3>
<div id="unit-name" style="color:#aaa">Select Unit</div>
<div id="unit-stats"></div>
</div>
<div class="panel">
<h3>Weapon Types</h3>
<div id="weapon-info">
<div class="wi"><b>🗡 Sword</b><br>Atk 15 Acc 90%<br>Range 1 Balanced</div>
<div class="wi"><b>🏹 Bow</b><br>Atk 10 Acc 80%<br>Range 2 Distance</div>
<div class="wi"><b>🔱 Spear</b><br>Atk 13 Acc 85%<br>Range 1 Counter+</div>
<div class="wi"><b>🪓 Axe</b><br>Atk 20 Acc 65%<br>Range 1 Critical+</div>
</div>
</div>
<div class="panel">
<h3>Combat log</h3>
<div id="log"></div>
</div>
</div>
</div>
<div id="phase">Turn 1 — Player turn</div>
<div id="bottom">
<button id="btn-end" onclick="endPlayerTurn()">Turn End</button>
<button id="btn-wait" onclick="waitUnit()" disabled>Wait</button>
<button id="btn-restart" class="danger" onclick="initGame()">Restart</button>
</div>
</div>
<script>
const COLS=10,ROWS=10,TS=40;
const cv=document.getElementById('c');
cv.width=COLS*TS;cv.height=ROWS*TS;
const ctx=cv.getContext('2d');
const WEAPONS={
sword:{name:'Sword',icon:'🗡',atk:15,hit:90,range:1,color:'#4fc3f7'},
bow: {name:'Bow',icon:'🏹',atk:10,hit:80,range:2,color:'#a5d6a7'},
spear:{name:'Spear',icon:'🔱',atk:13,hit:85,range:1,color:'#ce93d8',ctrAtk:5},
axe: {name:'Axe',icon:'🪓',atk:20,hit:65,range:1,color:'#ffab91'},
};
const TERRAIN={
plain:{name:'Yard',color:'#2d4a2d',def:0,move:1},
forest:{name:'Bush',color:'#1a3a1a',def:2,move:2},
hill:{name:'Hall',color:'#4a3a2a',def:1,move:2},
water:{name:'Water',color:'#1a2a4a',def:0,move:999},
};
let MAP=[],units=[],sel=null,phase='player',turn=1,moveHighlight=[],attackHighlight=[];
function mkMap(){
MAP=[];
const layout=[
'pppppppppf',
'ppfppphhpp',
'pffpppphpp',
'ppppwwpppp',
'ppphwwphpp',
'pppphhhppp',
'ppfpppppfp',
'pppppppppf',
'pfpphhpppf',
'pppppppppp',
];
const keys={p:'plain',f:'forest',h:'hill',w:'water'};
for(let r=0;r<ROWS;r++){MAP[r]=[];for(let c=0;c<COLS;c++)MAP[r][c]=keys[layout[r][c]]||'plain';}
}
function mkUnits(){
units=[
{id:0,name:'Sword man',team:'player',weapon:'sword',hp:30,maxHp:30,atk:0,def:3,spd:5,x:0,y:8,moved:false,acted:false,color:'#4fc3f7'},
{id:1,name:'Spear man',team:'player',weapon:'spear',hp:28,maxHp:28,atk:0,def:2,spd:4,x:1,y:9,moved:false,acted:false,color:'#ce93d8'},
{id:2,name:'Archer',team:'player',weapon:'bow',hp:22,maxHp:22,atk:0,def:1,spd:6,x:0,y:7,moved:false,acted:false,color:'#a5d6a7'},
{id:3,name:'Axe man',team:'player',weapon:'axe',hp:35,maxHp:35,atk:0,def:4,spd:3,x:2,y:9,moved:false,acted:false,color:'#ffab91'},
{id:4,name:'Sword goblin',team:'enemy',weapon:'sword',hp:20,maxHp:20,atk:0,def:1,spd:4,x:9,y:1,moved:false,acted:false,color:'#ef5350'},
{id:5,name:'Axe goblin',team:'enemy',weapon:'axe',hp:22,maxHp:22,atk:0,def:1,spd:3,x:8,y:0,moved:false,acted:false,color:'#ef5350'},
{id:6,name:'Spear goblin',team:'enemy',weapon:'spear',hp:18,maxHp:18,atk:0,def:2,spd:5,x:9,y:2,moved:false,acted:false,color:'#ff7043'},
{id:7,name:'Bow goblin',team:'enemy',weapon:'bow',hp:16,maxHp:16,atk:0,def:0,spd:6,x:7,y:0,moved:false,acted:false,color:'#ef5350'},
];
}
function initGame(){
mkMap();mkUnits();sel=null;phase='player';turn=1;moveHighlight=[];attackHighlight=[];
document.getElementById('log').innerHTML='';
setPhaseText();
render();
document.getElementById('btn-end').disabled=false;
}
function getUnit(x,y){return units.find(u=>u.x===x&&u.y===y&&u.hp>0);}
function inBounds(x,y){return x>=0&&y>=0&&x<COLS&&y<ROWS;}
function terrainMoveCost(x,y){return TERRAIN[MAP[y][x]].move;}
function getMoveCells(unit){
const moveRange=3;
const visited={};
const q=[{x:unit.x,y:unit.y,cost:0}];
visited[`${unit.x},${unit.y}`]=0;
const cells=[];
while(q.length){
const cur=q.shift();
cells.push({x:cur.x,y:cur.y});
const dirs=[[1,0],[-1,0],[0,1],[0,-1]];
for(const [dx,dy] of dirs){
const nx=cur.x+dx,ny=cur.y+dy;
const key=`${nx},${ny}`;
if(!inBounds(nx,ny))continue;
const cost=cur.cost+terrainMoveCost(nx,ny);
if(cost>moveRange)continue;
const occ=getUnit(nx,ny);
if(occ&&occ.team!==unit.team)continue;
if(visited[key]===undefined||visited[key]>cost){visited[key]=cost;q.push({x:nx,y:ny,cost});}
}
}
return cells.filter(c=>!(c.x===unit.x&&c.y===unit.y));
}
function getAttackCells(unit,fromX,fromY){
const wep=WEAPONS[unit.weapon];
const cells=[];
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const dist=Math.abs(c-fromX)+Math.abs(r-fromY);
if(dist>=1&&dist<=wep.range)cells.push({x:c,y:r});
}
return cells;
}
function dist(a,b){return Math.abs(a.x-b.x)+Math.abs(a.y-b.y);}
function calcDmg(attacker,defender){
const wep=WEAPONS[attacker.weapon];
const hit=Math.random()*100<wep.hit;
if(!hit)return{dmg:0,hit:false};
const dmg=Math.max(1,wep.atk+attacker.atk-defender.def+TERRAIN[MAP[defender.y][defender.x]].def*-1);
return{dmg,hit:true};
}
function doAttack(attacker,defender){
const awep=WEAPONS[attacker.weapon];
const dwep=WEAPONS[defender.weapon];
const {dmg,hit}=calcDmg(attacker,defender);
if(hit){defender.hp=Math.max(0,defender.hp-dmg);addLog(`${attacker.name}→${defender.name} ${dmg} Damage!`);}
else addLog(`${attacker.name}→${defender.name} Miss!`);
if(defender.hp>0){
const defRange=dwep.range;
const d=dist(attacker,defender);
if(d<=defRange){
const bonus=(defender.weapon==='spear'?dwep.ctrAtk||0:0);
const r2=calcDmg({...defender,atk:defender.atk+bonus},attacker);
if(r2.hit){attacker.hp=Math.max(0,attacker.hp-r2.dmg);addLog(`${defender.name}'s Counter! ${r2.dmg} Damage!`);}
else addLog(`${defender.name} Counter - Miss!`);
}
}
if(defender.hp<=0)addLog(`☠ ${defender.name} was defeat!`);
if(attacker.hp<=0)addLog(`☠ ${attacker.name} was defeat!`);
checkWin();
}
function addLog(msg){
const el=document.getElementById('log');
const d=document.createElement('div');d.textContent=msg;
el.appendChild(d);el.scrollTop=el.scrollHeight;
}
function checkWin(){
const pAlive=units.filter(u=>u.team==='player'&&u.hp>0);
const eAlive=units.filter(u=>u.team==='enemy'&&u.hp>0);
if(pAlive.length===0){setTimeout(()=>{addLog('💀 Enemies are victorious!');},100);}
else if(eAlive.length===0){setTimeout(()=>{addLog('🏆 Player is victorious!');},100);}
}
function setPhaseText(){
document.getElementById('phase').textContent=`Turn ${turn} — ${phase==='player'?'Player turn':'Enemy turn'}`;
}
// Rendering
const COLORS={
moveable:'rgba(80,180,255,0.25)',
attackable:'rgba(255,80,80,0.28)',
selected:'rgba(255,220,80,0.35)',
};
function render(){
ctx.clearRect(0,0,cv.width,cv.height);
// terrain
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
ctx.fillStyle=TERRAIN[MAP[r][c]].color;
ctx.fillRect(c*TS,r*TS,TS,TS);
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=0.5;
ctx.strokeRect(c*TS,r*TS,TS,TS);
}
// highlights
for(const h of moveHighlight){
ctx.fillStyle=COLORS.moveable;ctx.fillRect(h.x*TS,h.y*TS,TS,TS);
}
for(const h of attackHighlight){
ctx.fillStyle=COLORS.attackable;ctx.fillRect(h.x*TS,h.y*TS,TS,TS);
}
if(sel){
ctx.fillStyle=COLORS.selected;ctx.fillRect(sel.x*TS,sel.y*TS,TS,TS);
}
// units
for(const u of units){
if(u.hp<=0)continue;
const px=u.x*TS,py=u.y*TS;
ctx.fillStyle=u.team==='player'?(u.moved?'#555':u.color):(u.color);
ctx.beginPath();ctx.roundRect(px+5,py+5,TS-10,TS-10,4);ctx.fill();
if(u===sel){ctx.strokeStyle='#ffe066';ctx.lineWidth=2;ctx.stroke();}
const wep=WEAPONS[u.weapon];
ctx.font='16px serif';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(wep.icon,px+TS/2,py+TS/2);
// hp bar
const barW=TS-8,barH=4,bx=px+4,by=py+TS-8;
ctx.fillStyle='#222';ctx.fillRect(bx,by,barW,barH);
ctx.fillStyle=u.team==='player'?'#4caf50':'#f44336';
ctx.fillRect(bx,by,barW*(u.hp/u.maxHp),barH);
// team indicator
ctx.fillStyle=u.team==='player'?'#4fc3f7':'#ef5350';
ctx.fillRect(px+4,py+4,8,3);
}
}
// State machine
let state='idle'; // idle | selected | moved
let selMoves=[];
let selAttacks=[];
let movedPos=null;
cv.addEventListener('click',e=>{
const rect=cv.getBoundingClientRect();
const scaleX=cv.width/rect.width,scaleY=cv.height/rect.height;
const mx=Math.floor((e.clientX-rect.left)*scaleX/TS);
const my=Math.floor((e.clientY-rect.top)*scaleY/TS);
if(!inBounds(mx,my))return;
handleClick(mx,my);
});
function handleClick(cx,cy){
if(phase!=='player')return;
const clicked=getUnit(cx,cy);
if(state==='idle'){
if(clicked&&clicked.team==='player'&&!clicked.acted){
sel=clicked;
selMoves=getMoveCells(clicked);
selAttacks=getAttackCells(clicked,clicked.x,clicked.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
state='selected';
showUnitInfo(clicked);
document.getElementById('btn-wait').disabled=false;
}else{
clearSel();
}
} else if(state==='selected'){
// Click same unit = deselect
if(clicked===sel){clearSel();return;}
// Click enemy in range = attack
if(clicked&&clicked.team==='enemy'){
const inRange=selAttacks.some(a=>a.x===cx&&a.y===cy);
if(inRange){
doAttack(sel,clicked);
sel.acted=true;sel.moved=true;
clearSel();render();return;
}
}
// Click move cell
if(selMoves.some(m=>m.x===cx&&m.y===cy)&&!getUnit(cx,cy)){
movedPos={x:sel.x,y:sel.y};
sel.x=cx;sel.y=cy;
sel.moved=true;
// Show attack options from new pos
selAttacks=getAttackCells(sel,cx,cy);
const enemies=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
moveHighlight=[];
attackHighlight=enemies;
state='moved';
render();return;
}
// Click different ally
if(clicked&&clicked.team==='player'&&!clicked.acted){
sel=clicked;
selMoves=getMoveCells(clicked);
selAttacks=getAttackCells(clicked,clicked.x,clicked.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
showUnitInfo(clicked);
render();return;
}
clearSel();
} else if(state==='moved'){
// Click enemy to attack
if(clicked&&clicked.team==='enemy'){
const inRange=selAttacks.some(a=>a.x===cx&&a.y===cy);
if(inRange){
doAttack(sel,clicked);
sel.acted=true;
clearSel();render();return;
}
}
// Click empty = undo move
if(!clicked){
if(movedPos){sel.x=movedPos.x;sel.y=movedPos.y;sel.moved=false;}
selMoves=getMoveCells(sel);
selAttacks=getAttackCells(sel,sel.x,sel.y);
moveHighlight=selMoves;
attackHighlight=selAttacks.filter(a=>{const e=getUnit(a.x,a.y);return e&&e.team==='enemy';});
state='selected';movedPos=null;render();return;
}
}
render();
}
function clearSel(){
sel=null;state='idle';moveHighlight=[];attackHighlight=[];movedPos=null;
document.getElementById('btn-wait').disabled=true;
document.getElementById('unit-name').textContent='Select Unit';
document.getElementById('unit-name').style.color='#aaa';
document.getElementById('unit-stats').innerHTML='';
}
function waitUnit(){
if(!sel)return;
sel.moved=true;sel.acted=true;
clearSel();render();
}
function showUnitInfo(u){
const wep=WEAPONS[u.weapon];
document.getElementById('unit-name').textContent=`${wep.icon} ${u.name}`;
document.getElementById('unit-name').style.color=u.color;
document.getElementById('unit-stats').innerHTML=`
<div class="bar-row"><span class="bar-label">HP</span><div class="bar-bg"><div class="bar-fill hp-bar" style="width:${(u.hp/u.maxHp)*100}%"></div></div><span style="font-size:11px;margin-left:4px">${u.hp}/${u.maxHp}</span></div>
<div style="font-size:11px;color:#aaa;margin-top:4px">
Weapon: ${wep.name} | Atk: ${wep.atk} | Acc: ${wep.hit}%<br>
Range: ${wep.range} | Def: ${u.def} | Spd: ${u.spd}
</div>
<div style="font-size:11px;color:${u.moved?'#f66':'#4fc'};margin-top:3px">${u.acted?'Acted':u.moved?'Moved':'Movable'}</div>
`;
}
function endPlayerTurn(){
clearSel();
phase='enemy';
setPhaseText();
document.getElementById('btn-end').disabled=true;
render();
setTimeout(enemyTurn,600);
}
function enemyTurn(){
const enemies=units.filter(u=>u.team==='enemy'&&u.hp>0);
const players=units.filter(u=>u.team==='player'&&u.hp>0);
let i=0;
function doNext(){
if(i>=enemies.length){
// Reset
units.forEach(u=>{u.moved=false;u.acted=false;});
phase='player';turn++;
setPhaseText();
document.getElementById('btn-end').disabled=false;
render();return;
}
const enemy=enemies[i];i++;
if(enemy.hp<=0){doNext();return;}
// Find nearest player
const pAlive=units.filter(u=>u.team==='player'&&u.hp>0);
if(!pAlive.length){render();return;}
const target=pAlive.reduce((a,b)=>dist(enemy,a)<dist(enemy,b)?a:b);
const wep=WEAPONS[enemy.weapon];
const d=dist(enemy,target);
if(d<=wep.range){
// Attack
doAttack(enemy,target);
} else {
// Move toward target
const moves=getMoveCells(enemy);
// pick cell closest to target
let best=null,bestD=999;
for(const m of moves){
const md=Math.abs(m.x-target.x)+Math.abs(m.y-target.y);
if(md<bestD&&!getUnit(m.x,m.y)){bestD=md;best=m;}
}
if(best){enemy.x=best.x;enemy.y=best.y;}
// Try attack from new pos
const atks=getAttackCells(enemy,enemy.x,enemy.y);
const inRange=atks.find(a=>a.x===target.x&&a.y===target.y);
if(inRange)doAttack(enemy,target);
}
enemy.moved=true;enemy.acted=true;
render();
setTimeout(doNext,500);
}
doNext();
}
initGame();
</script>
</body>
</html>