Last active
April 26, 2025 23:23
-
-
Save kigiri/7e21077c97dedb185bfba991df042675 to your computer and use it in GitHub Desktop.
Tower defense vs Vampire Survivor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset='utf-8'> | |
<meta name='viewport' content='width=device-width, initial-scale=1'> | |
<title>TD</title> | |
</head> | |
<body> | |
<style> | |
html { | |
background: #111; | |
} | |
body { | |
display: flex; | |
height: 100vh; | |
overflow: hidden; | |
justify-content: center; | |
align-items: center; | |
} | |
svg { | |
border-radius: 8px; | |
} | |
</style> | |
<script> | |
const toPos = (x, y) => { | |
x = x < 0 ? GRID_W - 1 + x : x | |
y = y < 0 ? GRID_H - 1 + y : y | |
const pos = y * GRID_W + x | |
if (pos < 0 || pos >= GRID_MAX) return -1 | |
return Math.trunc(pos) | |
} | |
const toX = (pos) => (pos % GRID_W) | 0 | |
const toY = (pos) => (pos / GRID_W) | 0 | |
const GRID_H = 31 | |
const GRID_W = 61 | |
const GRID_MAX = GRID_H * GRID_W | |
const BASE = (GRID_MAX - 1) / 2 | |
const BASE_X = toX(BASE) | |
const BASE_Y = toY(BASE) | |
const TOP_LEFT_SPAWNER = toPos(4, 4) | |
const TOP_RIGHT_SPAWNER = toPos(4, -4) | |
const BOTTOM_RIGHT_SPAWNER = toPos(-4, -4) | |
const BOTTOM_LEFT_SPAWNER = toPos(-4, 4) | |
const UNREACHABLE = -4 | |
let WAVE = 1 | |
let FRAME = -1 | |
const spawners = [ | |
TOP_LEFT_SPAWNER, | |
TOP_RIGHT_SPAWNER, | |
BOTTOM_RIGHT_SPAWNER, | |
BOTTOM_LEFT_SPAWNER, | |
] | |
const [ | |
EMPTY, | |
OBSTACLE, | |
SPAWN, | |
] = Array(999).keys() | |
const state = new Uint16Array(GRID_MAX).fill(EMPTY) | |
const create = (tag) => | |
document.createElementNS('http://www.w3.org/2000/svg', tag) | |
const totalNeighbor = (src, pos) => | |
(~~src[pos - GRID_W - 1]) + (~~src[pos - GRID_W]) + (~~src[pos - GRID_W + 1]) | |
+ (~~src[pos - 1]) + (~~src[pos + 1]) | |
+ (~~src[pos + GRID_W - 1]) + (~~src[pos + GRID_W]) + (~~src[pos + GRID_W + 1]) | |
const totalCloseNeighbor = (src, pos) => | |
(~~src[pos - GRID_W]) | |
+ (~~src[pos - 1]) + (~~src[pos + 1]) | |
+ (~~src[pos + GRID_W]) | |
const permutations = a => | |
a.length < 2 ? [a] : a.reduce((r, e, i) => r.concat( | |
permutations([...a.slice(0, i), ...a.slice(i + 1)]).map(v => [e, ...v]) | |
), []) | |
const nearest = new Function('pos, type', ` | |
const r = Math.random() * 24 | |
${permutations(['pos - GRID_W', 'pos + GRID_W', 'pos - 1', 'pos + 1']) | |
.map((checks, i) => ` | |
if (r < ${i + 1}) { | |
${checks.map(check => `if (state[${check}] === type) return ${check}`).join('\n ')} | |
return -1 | |
}`).join('\n') | |
}`) | |
const destroy = (enemy) => { | |
enemy.hp = 0 | |
enemy.cx.value = -10 | |
enemy.cy.value = -10 | |
for (const { tower } of towerGrid[(~~enemy.y) * GRID_W + (~~enemy.x)]) { | |
tower.targets.delete(enemy) | |
} | |
} | |
// const nearest = (pos, type) => { | |
// if (state[pos - GRID_W] === type) return pos - GRID_W // top | |
// if (state[pos - 1] === type) return pos - 1 // right | |
// if (state[pos + GRID_W] === type) return pos + GRID_W // bottom | |
// if (state[pos + 1] === type) return pos + 1 // left | |
// } | |
const directionLookup = new Int16Array(GRID_MAX).fill(-1) | |
const computePathLookup = (() => { | |
const queue = new Uint16Array(GRID_MAX) | |
const tmp = new Int16Array(GRID_MAX).fill(-1) | |
const checkNeighbor = (index, x, offset) => { | |
const neighborIndex = index + offset | |
if ( | |
// Basic bounds check | |
(neighborIndex < 0 || neighborIndex >= GRID_MAX) || | |
// Moving right from last column | |
(offset === 1 && x === GRID_W - 1) || | |
// Moving left from first column | |
(offset === -1 && x === 0) || | |
// Check if visited | |
(tmp[neighborIndex] !== -1) || | |
// Check if not free | |
(state[neighborIndex] !== EMPTY) | |
) return | |
tmp[neighborIndex] = index // Point neighbor back to current cell | |
queue[queueTail++] = neighborIndex // Enqueue neighbor | |
} | |
return () => { | |
tmp.fill(-1) | |
queue.fill(0) | |
// BFS loop | |
queueTail = 0 | |
queueHead = -1 | |
tmp[BASE] = BASE | |
queue[queueTail++] = BASE | |
while (++queueHead < queueTail) { | |
const index = queue[queueHead] | |
const x = index % GRID_W // Needed for left/right boundary checks | |
checkNeighbor(index, x, -GRID_W) // North | |
checkNeighbor(index, x, GRID_W) // South | |
checkNeighbor(index, x, 1) // East | |
checkNeighbor(index, x, -1) // West | |
} | |
// Ensure we have a path available from our spawns | |
for (const spawn of spawners) { | |
if (totalCloseNeighbor(tmp, spawn) === UNREACHABLE) return false | |
tmp[spawn] = nearest(spawn, EMPTY) | |
// TODO: hardcode check direction depending on | |
// where in the map we are. | |
} | |
directionLookup.set(tmp) | |
return true | |
} | |
})() | |
const towers = [] | |
const towerGrid = Array(GRID_MAX).keys().map(() => []).toArray() | |
const addTowerToGrid = (tower) => { | |
towers.push(tower) | |
const towerRadius = create('circle') | |
const x = toX(tower.pos) | |
const y = toY(tower.pos) | |
const towerCenterX = (x + 0.5) * 36 // For drawing the visual circle | |
const towerCenterY = (y + 0.5) * 36 // For drawing the visual circle | |
towerRadius.setAttribute('fill', '#fff2') | |
towerRadius.setAttribute('r', tower.radius * 36) | |
towerRadius.setAttribute('cx', towerCenterX) | |
towerRadius.setAttribute('cy', towerCenterY) | |
svg.append(towerRadius) | |
const outerR = tower.radius + 0.5 | |
const innerR = tower.radius - 0.5 | |
const outerSq = outerR * outerR | |
const innerSq = innerR * innerR | |
const minX = Math.max(x - tower.radius, 0) | |
const maxX = Math.min(x + tower.radius, GRID_W - 1) | |
const minY = Math.max(y - tower.radius, 0) | |
const maxY = Math.min(y + tower.radius, GRID_H - 1) | |
for (let cx = minX; cx <= maxX; cx++) { | |
for (let cy = minY; cy <= maxY; cy++) { | |
const cellPos = toPos(cx, cy) | |
const elem = elems[cellPos] | |
if (!elem) continue | |
const diffX = cx - x | |
const diffY = cy - y | |
const distSq = diffX * diffX + diffY * diffY | |
distSq < outerSq && towerGrid[cellPos].push({ | |
x: x + 0.5, | |
y: y + 0.5, | |
dist: tower.radius * tower.radius, | |
tower, | |
outer: distSq > innerSq, | |
}) | |
} | |
} | |
} | |
const svg = create('svg') | |
const elemPos = new Map() | |
const elems = Array(GRID_MAX) | |
INIT_STATE: { | |
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') | |
svg.setAttribute('viewBox', `0 0 ${GRID_W * 36} ${GRID_H * 36}`) | |
for (const i of elems.keys()) { | |
const x = toX(i) | |
const y = toY(i) | |
const elem = create('rect') | |
elem.setAttribute('x', x * 36 + 1) | |
elem.setAttribute('y', y * 36 + 1) | |
elem.setAttribute('width', 32) | |
elem.setAttribute('height', 32) | |
elem.setAttribute('fill', '#2224') | |
elem.setAttribute('rx', 2) | |
elem.setAttribute('ry', 2) | |
elem.setAttribute('id', `pos${i}`) | |
elemPos.set(elem, i) | |
svg.append(elem) | |
elems[i] = elem | |
} | |
for (const pos of spawners) { | |
const elem = elems[pos] | |
state[pos] = SPAWN | |
elem.setAttribute('fill', 'hotpink') | |
elem.setAttribute('rx', 8) | |
elem.setAttribute('ry', 8) | |
} | |
// Set the base | |
const base = elems[BASE] | |
state[BASE] = BASE | |
base.setAttribute('fill', '#BADA55') | |
base.setAttribute('rx', 8) | |
base.setAttribute('ry', 8) | |
// Distribute random obstacles | |
const OBSTACLE_DENSITY = 0.15 // 15% | |
const OBSTACLE_COUNT = Math.round(OBSTACLE_DENSITY * GRID_MAX) | |
let obstaclePlaced = 0 | |
while (obstaclePlaced < OBSTACLE_COUNT) { | |
const pos = Math.round(Math.random() * GRID_MAX) | |
// count nearby empty blocks | |
if (state[pos] !== EMPTY) continue | |
const count = totalNeighbor(state, pos) | |
if (count > 0) continue | |
state[pos] = OBSTACLE | |
elems[pos].setAttribute('fill', '#444') | |
elems[pos].setAttribute('rx', 8) | |
elems[pos].setAttribute('ry', 8) | |
obstaclePlaced++ | |
} | |
// Inital path compute | |
computePathLookup() | |
document.body.append(svg) | |
} | |
const MAX_ENEMIES = 8192 | |
let enemies = [] | |
ENEMY_TEST: { | |
// break ENEMY_TEST | |
const start = performance.now() | |
let last = start | |
const freeSlots = state.map((s, i) => s === EMPTY ? i : 0).filter(Boolean) | |
const makeEnemy = (i) => { | |
// const spawnPos = BOTTOM_RIGHT_SPAWNER | |
// const spawnPos = spawners[~~(Math.random()* spawners.length)] | |
const spawnPos = freeSlots[~~(Math.random()* freeSlots.length)] | |
const elem = create('circle') | |
elem.setAttribute('fill', '#F45') | |
elem.setAttribute('r', 6) | |
elem.setAttribute('stroke-opacity', '0.5') | |
svg.append(elem) | |
return { | |
dir: directionLookup[spawnPos], | |
s: Math.random() / 50 + 0.015, | |
x: toX(spawnPos) + 0.5, | |
y: toY(spawnPos) + 0.5, | |
hp: 0, | |
cx: elem.cx.baseVal, | |
cy: elem.cy.baseVal, | |
elem, | |
} | |
} | |
enemies = Array(MAX_ENEMIES).keys().map(makeEnemy).toArray() | |
const near = n => ~~(n * 100) / 100 | |
const gameLoop = () => { | |
FRAME++ | |
let i = -1 | |
if (FRAME % 2048 === 0) { | |
WAVE++ | |
NEXT_SPAWN: for (const spawn of spawners) { | |
let count = 0 | |
for (const enemy of enemies) { | |
if (enemy.hp > 0) continue | |
if (++count > 10) continue NEXT_SPAWN | |
enemy.hp = 100 | |
enemy.x = toX(spawn) + 0.5 | |
enemy.y = toY(spawn) + 0.5 | |
enemy.dir = directionLookup[spawn] | |
enemy.elem.setAttribute('fill', `oklch(0.77 0.1197 ${275})`) | |
} | |
} | |
} | |
if (FRAME % 300 === 0) { | |
for (const tower of towers) { | |
console.log(tower.targets) | |
} | |
} | |
while (++i < GRID_MAX) { | |
if (state[i] !== OBSTACLE) continue | |
elems[i] | |
nearest() | |
} | |
// Update enemies | |
i = -1 | |
while (++i < MAX_ENEMIES) { | |
const enemy = enemies[i] | |
if (enemy.hp < 1) continue | |
const posX = ~~enemy.x | |
const posY = ~~enemy.y | |
const pos = posY * GRID_W + posX | |
// Hit the base !! | |
if (pos === BASE) { | |
destroy(enemy) | |
// TODO: damage the base | |
continue | |
} | |
// Check entering tower ranges | |
let inRange = false | |
for (const { x, y, outer, dist, tower } of towerGrid[pos]) { | |
if (outer) { | |
// check if in range, if not, continue | |
const diffX = enemy.x - x | |
const diffY = enemy.y - y | |
if ((diffX * diffX + diffY * diffY) > dist) { | |
tower.targets.delete(enemy) | |
continue | |
} | |
} | |
inRange = true | |
tower.targets.add(enemy) | |
} | |
if (inRange) { | |
enemy.elem.setAttribute('stroke', 'white') | |
} else { | |
enemy.elem.removeAttribute('stroke') | |
} | |
const dir = enemy.dir | |
const dirX = toX(dir) | |
const dirY = toY(dir) | |
const tY = dirY + 0.5 | |
const tX = dirX + 0.5 | |
const dx = tX - enemy.x | |
const dy = tY - enemy.y | |
if (Math.abs(dy) > 1e-6) { | |
const next = enemy.y + Math.sign(dy) * enemy.s | |
enemy.y = (dy > 1e-6 ? Math.min : Math.max)(next, tY) | |
enemy.x = tX | |
} else { | |
const next = enemy.x + Math.sign(dx) * enemy.s | |
enemy.x = (dx > 1e-6 ? Math.min : Math.max)(next, tX) | |
enemy.y = tY | |
} | |
if (enemy.y === tY && enemy.x === tX) { | |
enemy.dir = directionLookup[dir] | |
} | |
enemy.cx.value = enemy.x * 36 - 1 | |
enemy.cy.value = enemy.y * 36 - 1 | |
} | |
// Trigger towers attacks | |
for (const tower of towers) { | |
if (FRAME - tower.hit > tower.cooldown) { | |
tower.hit = FRAME | |
for (const enemy of tower.targets) { | |
enemy.hp -= Math.random() * 25 + 12.5 | |
if (enemy.hp < 1) { | |
destroy(enemy) | |
} else { | |
enemy.elem.setAttribute('fill', `oklch(0.77 0.1197 ${275 * (enemy.hp / 100)})`) | |
} | |
// TODO: Animate projectile | |
break | |
} | |
} | |
} | |
requestAnimationFrame(gameLoop) | |
} | |
gameLoop() | |
} | |
// TODO: | |
// - spawn in waves from spawners | |
// - make basic towers | |
// - basic towers attack logic | |
// - enemies health points & death | |
// - base health points | |
// - add economy | |
// - add more enemies | |
// - add more towers | |
// - tune difficulty | |
// - pan & zoom (?) | |
ADD_OBSTACLE: { | |
addEventListener('click', e => { | |
const pos = elemPos.get(e.target) | |
pos && addTowerToGrid({ | |
pos, | |
radius: 4, | |
targets: new Set(), | |
cooldown: 100, | |
hit: FRAME, | |
}) | |
/* | |
if (!pos || state[pos] !== EMPTY) return | |
const rollbackValue = state[pos] | |
state[pos] = OBSTACLE | |
if (computePathLookup()) { | |
elems[pos].setAttribute('fill', '#444') | |
elems[pos].setAttribute('rx', 8) | |
elems[pos].setAttribute('ry', 8) | |
let i = -1 | |
while (++i < MAX_ENEMIES) { | |
const enemy = enemies[i] | |
if (enemy.dir === pos) { | |
enemy.dir = nearest(pos, EMPTY) | |
// TODO: IDEAL behavior | |
// find the nearest free pos base of X / Y | |
// if very close to the center use the random one | |
// give a short speed boost while on the occupied block | |
} | |
} | |
} else { | |
state[pos] = rollbackValue | |
} | |
*/ | |
}) | |
} | |
DEBUG_DIRECTION: { | |
break DEBUG_DIRECTION | |
const getDirection = (pos) => | |
(directionLookup[pos] === -1 && 'obstacle') || | |
(directionLookup[pos] === pos - GRID_W && 'north') || | |
(directionLookup[pos] === pos + GRID_W && 'south') || | |
(directionLookup[pos] === pos + 1 && 'east') || | |
(directionLookup[pos] === pos - 1 && 'west') || 'center' | |
const colors = { | |
center: '#fff', | |
north: '#0ff2', | |
south: '#f0f2', | |
east: '#ff02', | |
west: '#0f02', | |
obstacle: '#000' | |
} | |
const setColors = () => { | |
for (const pos of elems.keys()) { | |
const dir = getDirection(pos) | |
if (state[pos] !== EMPTY && state[pos] !== OBSTACLE) continue | |
elems[pos].setAttribute('fill', colors[dir]) | |
} | |
} | |
requestAnimationFrame(function update() { | |
setColors() | |
setTimeout(() => { | |
requestAnimationFrame(update) | |
}, 50) | |
}) | |
} | |
// Every 10th of a level, you get 1 action, actions can be used for: | |
// Action: | |
// Destroy a tower or obstacle | |
// Place a tower | |
// Heal base | |
// every 30 level choose: | |
// Effect: | |
// Your reloading effects always target the slowest cooldown | |
// Your base slowly regenerate HP (instantly regen to full health the base) | |
// +1 rebound (+3 towers have rebound enchant) | |
// +50% crit damages (+3 towers are crit enchanted) | |
// +25% XP (gain one free level instantly) | |
// -25% cooldown (+3 towers have shorter cooldown enchant) | |
// double splash effect range (+3 towers have splash effect) | |
// Increase damages taken by afflicted enemies (+3 mighty towers) | |
// Always crit on enemy afflicted with ice (+3 icy towers) | |
// Double slow duration (+3 Sticky towers) | |
// Double burn duration (+3 Fiery towers) | |
// Your execute effect start at 40% instead of 20% (+3 Deadly towers) | |
// Beam +50% damage on beams (+3 beams equipements) | |
// Sentry +1 rebound on sentries (+3 sentry equipements) | |
// Artillery (+3 artillery equipements) | |
// One more choice per levels (choose between an any of the epic options) | |
// 1 rerolls every 10 level (gain 3 rerolls now) | |
// Every levels choose: | |
// [Common] Bonuses: | |
// Damage bonus (3.0% [common] | 4.5% [rare] | 6% [epic]) | |
// Crit chance bonus (5.0% [common] | 7.5% [rare] | 10% [epic]) | |
// Cooldown Reduction bonus (2.5% [common] | 3.7% [rare] | 5% [epic]) | |
// Rebound chances (5.0% [common] | 7.5% [rare] | 10% [epic]) | |
// Splash chances (5.0% [common] | 7.5% [rare] | 10% [epic]) | |
// [Uncommon] Stats: | |
// Increase base HP | |
// Reduce base damage taken | |
// Increase XP % | |
// Increase slow duration (must have at least one slow) | |
// Increase burn duration (must have at least one burn) | |
// Increase damage on frozen targets | |
// Increase slow % | |
// Increase burn % (dot effect) | |
// Increase execution damage (5.0% [common] | 7.5% [rare] | 10% [epic]) | |
// [Rare] Improve a tower: (a tower can be improved up to 3 times using one of those) | |
// Tower Equipement | |
// - Beam: Short range, super fast fire rate (lazer beam), low damages | |
// - Sentry: Medium range, normal fire rate (Shot, fast projectile), medium damage | |
// - Artillery: Long range, slow fire rate, very high damages (target enemy with most hp) | |
// Towers Enchantements: | |
// [epic] Icy: Stacking Freeze effect (can not move for a very short time, and increase damage taken, duration depend of initial tower cooldown) | |
// [uncommon] Sticky: Stacking Slow effect (medium duration) | |
// [rare] Fiery: Stacking Burn effect (10% of damages done) | |
// Tower Buffs: | |
// Lucky: 50% crit | |
// Shiny: Reduce cooldown by 25% | |
// Bouncy: 50% chances of rebound | |
// Mighty: 30% damage increase | |
// Splashy: 50% chance of splash damages | |
// Bounty: triple XP rewared from it's kills | |
// Deadly: Deals 300% bonus damage to enemies below 20% health. | |
// [Epic] Triggers: | |
// Blazing speed: On burn, reload a near by tower | |
// Kinetic Relay: On slow, reload a near by tower | |
// Cryo-Charge: freeze, reload a near by tower | |
// Eruption: On burn damage an near by enemy | |
// Backlash: On slow, damage an near by enemy | |
// Ice Shards: On freeze damage an near by enemy | |
// Frostfire: On freeze -> burn | |
// Ignition: On slow -> burn | |
// Meltdown: On burn -> slow | |
// Permafrost: On freeze -> slow | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment