<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Clash of Concubines</title>
<style>
body { margin:0; background:#0d0d12; display:flex; justify-content:center; align-items:center; min-height:100vh; font-family:'Segoe UI',system-ui,sans-serif; }
canvas { background:#1a2a1a; box-shadow:0 0 40px #000, 0 0 80px #6a1b3a; border-radius:6px; cursor:crosshair; }
</style>
</head>
<body>
<canvas id="c" width="480" height="800"></canvas>
<script>
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
const W = 480, H = 800;
const RIVER_TOP = 380, RIVER_BOT = 420;
const BRIDGES = [120, 360];
const CARDS = {
hellhound: {name:'Hellhound', cost:2, hp:200, dmg:40, spd:150, range:25, cd:0.7, r:11, color:'#c0392b',
desc:'Bitch in heat. Runs hot, dies fast, leaves a mess.'},
succubus: {name:'Succubus', cost:3, hp:350, dmg:55, spd:130, range:30, cd:0.9, r:13, color:'#8e44ad', lifesteal:0.5,
desc:'Drains life with every touch. Sloppy, greedy, effective.'},
incubus: {name:'Incubus', cost:4, hp:900, dmg:80, spd:70, range:30, cd:1.2, r:17, color:'#6d4c41',
desc:'Big, slow, hung like a siege engine. Soaks damage.'},
banshee: {name:'Banshee', cost:3, hp:250, dmg:70, spd:90, range:130, cd:1.1, r:12, color:'#5dade2',
desc:'Screams from range. Glass cannon. Moans when she hits.'},
dominatrix:{name:'Dominatrix', cost:5, hp:600, dmg:130, spd:100, range:30, cd:1.0, r:14, color:'#e74c3c',
desc:'Whip crack hits hard. Single target. No mercy.'},
broodmother:{name:'Broodmother', cost:5, hp:700, dmg:50, spd:80, range:30, cd:1.0, r:16, color:'#27ae60',
desc:'Spits out spawn on death. Fertile to the end.', onDeath:'imp', onDeathCount:3},
concubine: {name:'Concubine', cost:4, hp:400, dmg:0, spd:90, range:120, cd:1.0, r:12, color:'#f39c12', heal:25,
desc:'Heals allies with her presence. Keeps the line wet.'},
tentacle: {name:'Tentacle Horror', cost:6, hp:1200, dmg:60, spd:50, range:30, cd:1.0, r:19, color:'#16a085', spawner:'tentacle_sm', spawnCd:3,
desc:'Tanky spawner. Wraps and fills. Spawns tentacles.'},
imp: {name:'Imp', cost:1, hp:100, dmg:20, spd:160, range:25, cd:0.6, r:8, color:'#d35400',
desc:'Fodder. Fills a hole. That is all.'},
tentacle_sm:{name:'Tentacle', cost:1, hp:150, dmg:25, spd:100, range:25, cd:0.8, r:9, color:'#1abc9c',
desc:'A smaller tentacle. Still gets the job done.'}
};
const DECK = ['hellhound','succubus','incubus','banshee','dominatrix','broodmother','concubine','tentacle'];
let S;
function init() {
S = {
time: 0, over: false, winner: null,
elixir: 5, aiElixir: 5,
selected: -1,
playerDeck: shuffle([...DECK]),
aiDeck: shuffle([...DECK]),
units: [], towers: [], uid: 1,
aiTimer: 2,
towers_destroyed: {player:0, ai:0}
};
// towers: team 'player' bottom, 'ai' top
S.towers.push(mkTower('ai', 120, 200, false), mkTower('ai', 360, 200, false), mkTower('ai', 240, 70, true));
S.towers.push(mkTower('player', 120, 600, false), mkTower('player', 360, 600, false), mkTower('player', 240, 730, true));
}
function shuffle(a){ for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];} return a; }
function mkTower(team, x, y, king) {
return {team, x, y, king, hp: king?2400:1400, maxHp: king?2400:1400,
dmg: king?70:50, cd: king?1.0:0.8, atkTimer:0, range: king?160:140, r: king?26:20, lane: x<240?'L':'R'};
}
function spawnUnit(team, key, x, y) {
const c = CARDS[key]; if (!c) return;
S.units.push({ id:S.uid++, team, key, x, y, hp:c.hp, maxHp:c.hp, dmg:c.dmg, spd:c.spd,
range:c.range, cd:c.cd, atkTimer:0, r:c.r, color:c.color,
lifesteal:c.lifesteal||0, heal:c.heal||0, spawner:c.spawner||null, spawnCd:c.spawnCd||0, spawnTimer:0,
onDeath:c.onDeath||null, onDeathCount:c.onDeathCount||0,
lane: x<240?'L':'R', hitFlash:0 });
}
function nearestBridge(x) { return Math.abs(x-BRIDGES[0]) < Math.abs(x-BRIDGES[1]) ? BRIDGES[0] : BRIDGES[1]; }
function updateUnit(u, dt) {
if (u.hp <= 0) return;
// spawner
if (u.spawner) { u.spawnTimer += dt; if (u.spawnTimer >= u.spawnCd) { u.spawnTimer = 0; spawnUnit(u.team, u.spawner, u.x + (Math.random()-0.5)*30, u.y + (Math.random()-0.5)*30); } }
// heal aura
if (u.heal) {
for (const o of S.units) if (o !== u && o.team === u.team && o.hp > 0 && dist(o,u) < u.range) o.hp = Math.min(o.maxHp, o.hp + u.heal*dt);
}
// target
let target = null, bestD = 1e9;
for (const o of S.units) {
if (o.team === u.team || o.hp <= 0) continue;
const d = dist(u,o);
if (d < 150 && d < bestD) { bestD = d; target = o; }
}
if (!target) {
const enemy = u.team === 'player' ? 'ai' : 'player';
const princess = S.towers.find(t => t.team === enemy && !t.king && t.lane === u.lane && t.hp > 0);
target = princess || S.towers.find(t => t.team === enemy && t.king && t.hp > 0);
}
if (!target) return;
const d = dist(u, target);
const reach = u.range + (target.r || 20);
if (d <= reach) {
u.atkTimer -= dt;
if (u.atkTimer <= 0) {
u.atkTimer = u.cd;
target.hp -= u.dmg;
target.hitFlash = 0.12;
if (u.lifesteal) u.hp = Math.min(u.maxHp, u.hp + u.dmg * u.lifesteal);
}
} else {
// move toward target, routing through bridge if crossing river
let tx = target.x, ty = target.y;
const crossUp = u.y > RIVER_BOT && ty < RIVER_TOP;
const crossDown = u.y < RIVER_TOP && ty > RIVER_BOT;
if ((crossUp || crossDown) && Math.abs(u.x - nearestBridge(u.x)) > 20) {
tx = nearestBridge(u.x); ty = (RIVER_TOP+RIVER_BOT)/2;
}
const dx = tx - u.x, dy = ty - u.y, m = Math.hypot(dx,dy) || 1;
u.x += (dx/m)*u.spd*dt; u.y += (dy/m)*u.spd*dt;
}
if (u.hitFlash > 0) u.hitFlash -= dt;
}
function updateTower(t, dt) {
if (t.hp <= 0) return;
t.atkTimer -= dt;
if (t.atkTimer > 0) return;
let target = null, bestD = 1e9;
for (const u of S.units) {
if (u.team === t.team || u.hp <= 0) continue;
const d = dist(t,u);
if (d < t.range && d < bestD) { bestD = d; target = u; }
}
if (target) { t.atkTimer = t.cd; target.hp -= t.dmg; target.hitFlash = 0.12; }
}
function separate(dt) {
for (let i=0;i<S.units.length;i++) for (let j=i+1;j<S.units.length;j++) {
const a = S.units[i], b = S.units[j];
if (a.hp <= 0 || b.hp <= 0) continue;
const dx = b.x-a.x, dy = b.y-a.y, d = Math.hypot(dx,dy) || 0.01;
const min = a.r + b.r;
if (d < min) {
const push = (min-d)/2, nx = dx/d, ny = dy/d;
a.x -= nx*push; a.y -= ny*push; b.x += nx*push; b.y += ny*push;
}
}
}
function dist(a,b){ return Math.hypot(a.x-b.x, a.y-b.y); }
function update(dt) {
if (S.over) return;
S.time += dt;
S.elixir = Math.min(10, S.elixir + dt/2.8);
S.aiElixir = Math.min(10, S.aiElixir + dt/2.8);
for (const u of S.units) updateUnit(u, dt);
for (const t of S.towers) updateTower(t, dt);
separate(dt);
// deaths
for (const u of S.units) {
if (u.hp <= 0 && !u._dead) {
u._dead = true;
if (u.onDeath) for (let i=0;i<u.onDeathCount;i++) spawnUnit(u.team, u.onDeath, u.x+(Math.random()-0.5)*40, u.y+(Math.random()-0.5)*40);
}
}
S.units = S.units.filter(u => u.hp > 0);
// towers dead
for (const t of S.towers) {
if (t.hp <= 0 && !t._dead) {
t._dead = true;
if (t.king) { S.over = true; S.winner = t.team === 'player' ? 'ai' : 'player'; }
}
}
// AI
S.aiTimer -= dt;
if (S.aiTimer <= 0) {
const hand = S.aiDeck.slice(0,4);
const affordable = hand.map((k,i)=>({k,i,c:CARDS[k]})).filter(o=>o.c.cost <= S.aiElixir);
if (affordable.length) {
const pick = affordable[Math.floor(Math.random()*affordable.length)];
const lane = Math.random() < 0.5 ? 120 : 360;
const y = 260 + Math.random()*80;
spawnUnit('ai', pick.k, lane + (Math.random()-0.5)*40, y);
S.aiElixir -= pick.c.cost;
const [c] = S.aiDeck.splice(pick.i,1); S.aiDeck.push(c);
S.aiTimer = 1.8 + Math.random()*1.6;
} else {
S.aiTimer = 0.6;
}
}
}
function draw() {
ctx.clearRect(0,0,W,H);
// field
ctx.fillStyle = '#243d24'; ctx.fillRect(0,0,W,H);
// river
const g = ctx.createLinearGradient(0,RIVER_TOP,0,RIVER_BOT);
g.addColorStop(0,'#1a4a6a'); g.addColorStop(0.5,'#2a6a9a'); g.addColorStop(1,'#1a4a6a');
ctx.fillStyle = g; ctx.fillRect(0,RIVER_TOP,W,RIVER_BOT-RIVER_TOP);
// bridges
for (const bx of BRIDGES) {
ctx.fillStyle = '#5d4037'; ctx.fillRect(bx-30, RIVER_TOP-6, 60, RIVER_BOT-RIVER_TOP+12);
ctx.fillStyle = '#3e2723';
for (let i=0;i<6;i++) ctx.fillRect(bx-30, RIVER_TOP-6 + i*((RIVER_BOT-RIVER_TOP+12)/6), 60, 2);
}
// deploy zone hint
if (S.selected >= 0) {
ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(0, RIVER_BOT, W, H-RIVER_BOT);
}
// towers
for (const t of S.towers) {
if (t.hp <= 0) { ctx.fillStyle='rgba(60,60,60,0.5)'; ctx.beginPath(); ctx.arc(t.x,t.y,t.r,0,7); ctx.fill(); continue; }
ctx.fillStyle = t.team === 'player' ? '#2980b9' : '#c0392b';
ctx.beginPath(); ctx.arc(t.x, t.y, t.r, 0, 7); ctx.fill();
if (t.hitFlash > 0) { ctx.fillStyle = 'rgba(255,255,255,'+ (t.hitFlash*6) +')'; ctx.beginPath(); ctx.arc(t.x,t.y,t.r,0,7); ctx.fill(); t.hitFlash -= 0.016; }
// hp bar
ctx.fillStyle = '#000'; ctx.fillRect(t.x-30, t.y-t.r-9, 60, 5);
ctx.fillStyle = t.team === 'player' ? '#3498db' : '#e74c3c';
ctx.fillRect(t.x-30, t.y-t.r-9, 60*(t.hp/t.maxHp), 5);
}
// units
for (const u of S.units) {
ctx.fillStyle = u.color;
ctx.beginPath(); ctx.arc(u.x, u.y, u.r, 0, 7); ctx.fill();
if (u.hitFlash > 0) { ctx.fillStyle = 'rgba(255,255,255,'+(u.hitFlash*6)+')'; ctx.beginPath(); ctx.arc(u.x,u.y,u.r,0,7); ctx.fill(); }
ctx.strokeStyle = u.team === 'player' ? '#5dade2' : '#e74c3c';
ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(u.x,u.y,u.r,0,7); ctx.stroke();
// hp bar
const bw = u.r*2;
ctx.fillStyle = '#000'; ctx.fillRect(u.x-bw/2, u.y-u.r-7, bw, 3);
ctx.fillStyle = u.team === 'player' ? '#3498db' : '#e74c3c';
ctx.fillRect(u.x-bw/2, u.y-u.r-7, bw*(u.hp/u.maxHp), 3);
}
// elixir bar
ctx.fillStyle = '#000'; ctx.fillRect(10, H-70, W-20, 18);
const eg = ctx.createLinearGradient(10,0,W-20,0);
eg.addColorStop(0,'#8e44ad'); eg.addColorStop(1,'#e91e63');
ctx.fillStyle = eg; ctx.fillRect(10, H-70, (W-20)*(S.elixir/10), 18);
ctx.fillStyle = '#fff'; ctx.font = 'bold 12px monospace'; ctx.textAlign = 'center';
ctx.fillText(Math.floor(S.elixir)+' / 10', W/2, H-56);
// hand
const hand = S.playerDeck.slice(0,4);
for (let i=0;i<4;i++) {
const x = 10 + i*(115+4), y = H-46, w = 112, h = 40;
const key = hand[i], c = CARDS[key];
const sel = S.selected === i;
const aff = S.elixir >= c.cost;
ctx.fillStyle = sel ? '#e91e63' : (aff ? '#2c3e50' : '#1a1a1a');
ctx.fillRect(x,y,w,h);
ctx.strokeStyle = sel ? '#fff' : (aff ? '#555' : '#333'); ctx.lineWidth = 2;
ctx.strokeRect(x,y,w,h);
ctx.fillStyle = aff ? '#fff' : '#666'; ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'left';
ctx.fillText(c.name, x+6, y+16);
ctx.font = '10px sans-serif'; ctx.fillStyle = aff ? '#ffd54f' : '#555';
ctx.fillText('▮'+c.cost, x+6, y+32);
ctx.fillStyle = c.color; ctx.beginPath(); ctx.arc(x+w-16, y+h/2, 8, 0, 7); ctx.fill();
}
if (S.over) {
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(0,0,W,H);
ctx.fillStyle = S.winner === 'player' ? '#e91e63' : '#e74c3c';
ctx.font = 'bold 40px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(S.winner === 'player' ? 'VICTORY' : 'DEFEAT', W/2, H/2-20);
ctx.fillStyle = '#fff'; ctx.font = '16px sans-serif';
ctx.fillText('click to play again', W/2, H/2+30);
}
}
function pointIn(x,y,rx,ry,rw,rh){ return x>=rx && x<=rx+rw && y>=ry && y<=ry+rh; }
cv.addEventListener('click', e => {
const r = cv.getBoundingClientRect();
const mx = (e.clientX - r.left) * (W/r.width);
const my = (e.clientY - r.top) * (H/r.height);
if (S.over) { init(); return; }
// card select
for (let i=0;i<4;i++) {
if (pointIn(mx,my, 10+i*119, H-46, 112, 40)) {
const c = CARDS[S.playerDeck[i]];
if (S.elixir >= c.cost) S.selected = (S.selected === i ? -1 : i);
return;
}
}
// deploy
if (S.selected >= 0 && my > RIVER_BOT && my < H-50) {
const key = S.playerDeck[S.selected];
const c = CARDS[key];
if (S.elixir >= c.cost) {
spawnUnit('player', key, mx, my);
S.elixir -= c.cost;
const [card] = S.playerDeck.splice(S.selected,1); S.playerDeck.push(card);
S.selected = -1;
}
}
});
let last = performance.now();
function loop(t) {
const dt = Math.min(0.05, (t-last)/1000); last = t;
update(dt); draw();
requestAnimationFrame(loop);
}
init();
requestAnimationFrame(loop);
</script>
</body>
</html>1
1
13KB
13KB
94.0ms
204.0ms
94.0ms