Created
July 9, 2026 19:28
-
-
Save senko/cee877674a9e7060add155fec7ae72f1 to your computer and use it in GitHub Desktop.
RTS game by GPT-5.6 Terra
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 lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Frontier Command</title> | |
| <link rel="icon" href="data:,"> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --ink: #0d171b; | |
| --panel: #142126; | |
| --panel-2: #1c2d33; | |
| --line: #38525a; | |
| --text: #e7f1ec; | |
| --muted: #91a8aa; | |
| --accent: #64d7b3; | |
| --amber: #f0b75b; | |
| --danger: #e46b57; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| min-width: 760px; | |
| height: 100vh; | |
| overflow: hidden; | |
| background: #0a1114; | |
| color: var(--text); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| letter-spacing: 0; | |
| } | |
| #game-shell { | |
| height: 100vh; | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 290px; | |
| grid-template-rows: 58px minmax(0, 1fr); | |
| background: #0a1114; | |
| } | |
| header { | |
| grid-column: 1 / -1; | |
| display: flex; | |
| align-items: center; | |
| gap: 24px; | |
| padding: 0 18px; | |
| border-bottom: 1px solid #30454c; | |
| background: #132025; | |
| box-shadow: 0 2px 12px #05090b88; | |
| z-index: 3; | |
| } | |
| .brand { | |
| min-width: 178px; | |
| font-weight: 800; | |
| font-size: 15px; | |
| line-height: 1; | |
| color: #f2f6e9; | |
| text-transform: uppercase; | |
| letter-spacing: 1.2px; | |
| } | |
| .brand small { display: block; margin-top: 5px; color: var(--accent); font-size: 9px; letter-spacing: 2.3px; } | |
| #resources { display: flex; align-items: stretch; height: 100%; } | |
| .resource { | |
| display: flex; | |
| align-items: center; | |
| gap: 9px; | |
| min-width: 106px; | |
| padding: 0 16px; | |
| border-left: 1px solid #294148; | |
| color: var(--text); | |
| font-variant-numeric: tabular-nums; | |
| font-weight: 750; | |
| } | |
| .resource:last-child { border-right: 1px solid #294148; } | |
| .resource-icon { width: 14px; height: 14px; display: inline-block; } | |
| .wood { background: #5a9b69; border-radius: 2px; transform: rotate(45deg); } | |
| .ore { background: #66b9d6; clip-path: polygon(50% 0, 95% 35%, 76% 100%, 23% 100%, 4% 35%); } | |
| .people { border: 2px solid var(--amber); border-radius: 50%; position: relative; } | |
| .people::after { content: ""; position: absolute; width: 18px; height: 7px; border: 2px solid var(--amber); border-bottom: 0; left: -4px; top: 10px; border-radius: 12px 12px 0 0; } | |
| #status { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| #field { grid-column: 1; grid-row: 2; position: relative; overflow: hidden; min-width: 0; min-height: 0; background: #182f2a; } | |
| #game { width: 100%; height: 100%; display: block; cursor: crosshair; } | |
| #selection-box { display: none; position: absolute; border: 1px solid #a8f5db; background: #6ce6c528; pointer-events: none; z-index: 2; } | |
| aside { | |
| grid-column: 2; | |
| grid-row: 2; | |
| border-left: 1px solid #30454c; | |
| background: #101c21; | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| z-index: 2; | |
| } | |
| .panel { padding: 15px; border-bottom: 1px solid #2d454c; } | |
| .panel-title { margin: 0 0 11px; color: #a9c1bd; font-size: 10px; font-weight: 800; letter-spacing: 1.4px; text-transform: uppercase; } | |
| #selection-info { min-height: 94px; } | |
| .selection-name { font-size: 17px; font-weight: 800; margin-bottom: 5px; } | |
| .selection-subtitle { color: var(--muted); font-size: 12px; line-height: 1.45; } | |
| .meter { height: 6px; background: #091114; border: 1px solid #263b40; margin-top: 11px; overflow: hidden; } | |
| .meter > span { display: block; height: 100%; background: #65cda8; } | |
| .build-grid, .action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } | |
| button { | |
| position: relative; | |
| border: 1px solid #355057; | |
| border-radius: 4px; | |
| background: #1a2a30; | |
| color: #e6eeeb; | |
| min-height: 56px; | |
| padding: 8px; | |
| cursor: pointer; | |
| text-align: left; | |
| font: inherit; | |
| transition: background .14s ease, border-color .14s ease, transform .14s ease; | |
| } | |
| button:hover:not(:disabled) { background: #243a3f; border-color: #62bca5; } | |
| button:active:not(:disabled) { transform: translateY(1px); } | |
| button:disabled { cursor: not-allowed; opacity: .42; } | |
| button.armed { border-color: #eebe62; background: #3a3020; box-shadow: inset 0 0 0 1px #eebe6244; } | |
| .cmd-icon { float: left; display: grid; place-items: center; width: 26px; height: 26px; margin-right: 7px; color: var(--accent); font-weight: 900; font-size: 18px; } | |
| .cmd-label { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 750; } | |
| .cmd-cost { display: block; margin-top: 4px; color: #9ab2b3; font-size: 10px; font-variant-numeric: tabular-nums; } | |
| #action-panel { display: none; } | |
| #action-panel.visible { display: block; } | |
| #minimap-panel { margin-top: auto; padding-bottom: 14px; } | |
| #minimap { width: 100%; aspect-ratio: 1.46; display: block; border: 1px solid #39545a; background: #132624; cursor: crosshair; } | |
| .minimap-caption { display: flex; justify-content: space-between; margin-top: 8px; color: #7e9897; font-size: 10px; text-transform: uppercase; letter-spacing: .9px; } | |
| #toast { position: absolute; left: 15px; bottom: 15px; max-width: 430px; padding: 9px 11px; border-left: 3px solid var(--accent); background: #0b1519e8; color: #d5e4de; font-size: 12px; opacity: 0; transform: translateY(5px); transition: opacity .18s, transform .18s; pointer-events: none; } | |
| #toast.show { opacity: 1; transform: translateY(0); } | |
| @media (max-width: 850px) { | |
| body { min-width: 0; } | |
| #game-shell { grid-template-columns: minmax(0, 1fr) 235px; } | |
| header { gap: 8px; padding: 0 10px; } | |
| .brand { min-width: 115px; font-size: 12px; } | |
| .resource { min-width: 72px; padding: 0 9px; } | |
| #status { display: none; } | |
| aside .panel { padding: 11px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main id="game-shell"> | |
| <header> | |
| <div class="brand">Frontier Command<small>Sector L-17</small></div> | |
| <div id="resources"> | |
| <div class="resource"><i class="resource-icon wood"></i><span id="wood-count">0</span></div> | |
| <div class="resource"><i class="resource-icon ore"></i><span id="ore-count">0</span></div> | |
| <div class="resource"><i class="resource-icon people"></i><span id="pop-count">0 / 0</span></div> | |
| </div> | |
| <div id="status">Establish the frontier.</div> | |
| </header> | |
| <section id="field" aria-label="Game map"> | |
| <canvas id="game"></canvas> | |
| <div id="selection-box"></div> | |
| <div id="toast"></div> | |
| </section> | |
| <aside> | |
| <section class="panel" id="selection-info"> | |
| <h2 class="panel-title">Selection</h2> | |
| <div id="selection-content"> | |
| <div class="selection-name">Command Nexus</div> | |
| <div class="selection-subtitle">Coordinate production and expand the frontier.</div> | |
| <div class="meter"><span style="width:100%"></span></div> | |
| </div> | |
| </section> | |
| <section class="panel"> | |
| <h2 class="panel-title">Construction</h2> | |
| <div class="build-grid" id="build-grid"> | |
| <button data-build="outpost" title="Build Outpost"><span class="cmd-icon">⌂</span><span class="cmd-label">Outpost</span><span class="cmd-cost">90 timber · 30 ore</span></button> | |
| <button data-build="lumberyard" title="Build Lumberyard"><span class="cmd-icon">⌁</span><span class="cmd-label">Lumberyard</span><span class="cmd-cost">120 timber · 35 ore</span></button> | |
| <button data-build="refinery" title="Build Refinery"><span class="cmd-icon">◈</span><span class="cmd-label">Refinery</span><span class="cmd-cost">90 timber · 95 ore</span></button> | |
| <button data-build="barracks" title="Build Barracks"><span class="cmd-icon">⚑</span><span class="cmd-label">Barracks</span><span class="cmd-cost">160 timber · 75 ore</span></button> | |
| <button data-build="beacon" title="Build Survey Beacon"><span class="cmd-icon">✦</span><span class="cmd-label">Survey Beacon</span><span class="cmd-cost">80 timber · 130 ore</span></button> | |
| </div> | |
| </section> | |
| <section class="panel" id="action-panel"> | |
| <h2 class="panel-title" id="action-title">Production</h2> | |
| <div class="action-grid" id="action-grid"></div> | |
| </section> | |
| <section class="panel" id="minimap-panel"> | |
| <canvas id="minimap" width="246" height="168" aria-label="Minimap"></canvas> | |
| <div class="minimap-caption"><span>Exploration map</span><span id="map-progress">0%</span></div> | |
| </section> | |
| </aside> | |
| </main> | |
| <script> | |
| (() => { | |
| 'use strict'; | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| const mini = document.getElementById('minimap'); | |
| const mctx = mini.getContext('2d'); | |
| const field = document.getElementById('field'); | |
| const selectionBox = document.getElementById('selection-box'); | |
| const toastEl = document.getElementById('toast'); | |
| const statusEl = document.getElementById('status'); | |
| const actionPanel = document.getElementById('action-panel'); | |
| const actionGrid = document.getElementById('action-grid'); | |
| const selectionContent = document.getElementById('selection-content'); | |
| const buildGrid = document.getElementById('build-grid'); | |
| const TILE = 48; | |
| const MAP_W = 34; | |
| const MAP_H = 24; | |
| const WORLD_W = MAP_W * TILE; | |
| const WORLD_H = MAP_H * TILE; | |
| const resources = { wood: 520, ore: 390 }; | |
| let population = 0; | |
| let populationCap = 0; | |
| let nextId = 1; | |
| let time = 0; | |
| let last = performance.now(); | |
| let camera = { x: 0, y: 0 }; | |
| let viewport = { w: 1, h: 1, dpr: 1 }; | |
| let selected = []; | |
| let selectedBuilding = null; | |
| let buildMode = null; | |
| let mouse = { x: 0, y: 0, worldX: 0, worldY: 0, down: false, startX: 0, startY: 0 }; | |
| let toastTimer = 0; | |
| const keys = new Set(); | |
| const BUILDINGS = { | |
| nexus: { name: 'Command Nexus', w: 4, h: 3, hp: 900, vision: 5, supply: 8, color: '#62cfaa', production: ['worker', 'scout'] }, | |
| outpost: { name: 'Outpost', w: 2, h: 2, hp: 420, vision: 4, supply: 4, color: '#e9b85e' }, | |
| lumberyard: { name: 'Lumberyard', w: 3, h: 2, hp: 520, vision: 3, color: '#8fd36e' }, | |
| refinery: { name: 'Refinery', w: 3, h: 2, hp: 540, vision: 3, color: '#66b8d4' }, | |
| barracks: { name: 'Barracks', w: 3, h: 3, hp: 700, vision: 3, color: '#df7859', production: ['guard', 'scout'] }, | |
| beacon: { name: 'Survey Beacon', w: 2, h: 2, hp: 360, vision: 6, color: '#b77bd8', scan: true } | |
| }; | |
| const BUILD_COSTS = { | |
| outpost: { wood: 90, ore: 30 }, lumberyard: { wood: 120, ore: 35 }, refinery: { wood: 90, ore: 95 }, | |
| barracks: { wood: 160, ore: 75 }, beacon: { wood: 80, ore: 130 } | |
| }; | |
| const UNIT_TYPES = { | |
| worker: { name: 'Field Engineer', hp: 80, speed: 77, vision: 3, cost: { wood: 35, ore: 10 }, time: 2.6, color: '#72d4b1', role: 'worker' }, | |
| scout: { name: 'Pathfinder', hp: 65, speed: 126, vision: 5, cost: { wood: 30, ore: 25 }, time: 2.1, color: '#e5c45f', role: 'scout' }, | |
| guard: { name: 'Vanguard', hp: 135, speed: 68, vision: 3.5, cost: { wood: 55, ore: 65 }, time: 3.2, color: '#dd785b', role: 'guard' } | |
| }; | |
| const terrain = []; | |
| const revealed = Array.from({ length: MAP_H }, () => Array(MAP_W).fill(false)); | |
| const nodes = []; | |
| const buildings = []; | |
| const units = []; | |
| function hash(x, y) { | |
| let n = Math.imul(x + 163, 374761393) + Math.imul(y + 41, 668265263); | |
| n = (n ^ (n >>> 13)) * 1274126177; | |
| return ((n ^ (n >>> 16)) >>> 0) / 4294967296; | |
| } | |
| function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } | |
| function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } | |
| function worldPoint(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { x: (e.clientX - rect.left) + camera.x, y: (e.clientY - rect.top) + camera.y }; | |
| } | |
| function centerOf(b) { return { x: (b.x + b.w * TILE / 2), y: (b.y + b.h * TILE / 2) }; } | |
| function status(text) { statusEl.textContent = text; } | |
| function toast(text) { | |
| toastEl.textContent = text; | |
| toastEl.classList.add('show'); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2100); | |
| } | |
| function hasCost(cost) { return resources.wood >= cost.wood && resources.ore >= cost.ore; } | |
| function pay(cost) { resources.wood -= cost.wood; resources.ore -= cost.ore; } | |
| function makeTerrain() { | |
| for (let y = 0; y < MAP_H; y++) { | |
| terrain[y] = []; | |
| for (let x = 0; x < MAP_W; x++) { | |
| const r = hash(x, y); | |
| terrain[y][x] = r > .81 ? 2 : r > .56 ? 1 : 0; | |
| } | |
| } | |
| addNode('wood', 8, 5, 6); addNode('wood', 12, 16, 7); addNode('wood', 26, 8, 7); addNode('wood', 29, 19, 8); addNode('wood', 5, 19, 5); | |
| addNode('ore', 18, 5, 5); addNode('ore', 20, 17, 6); addNode('ore', 31, 13, 5); addNode('ore', 9, 11, 4); addNode('ore', 3, 15, 4); | |
| } | |
| function addNode(type, tx, ty, size) { | |
| nodes.push({ id: nextId++, type, x: tx * TILE + TILE / 2, y: ty * TILE + TILE / 2, radius: 20 + size * 2, amount: 900 + size * 110 }); | |
| } | |
| function addBuilding(type, tx, ty, complete = true) { | |
| const d = BUILDINGS[type]; | |
| const b = { id: nextId++, type, x: tx * TILE, y: ty * TILE, w: d.w, h: d.h, hp: d.hp, maxHp: d.hp, progress: complete ? 1 : 0, queue: [], production: 0 }; | |
| buildings.push(b); | |
| if (complete && d.supply) populationCap += d.supply; | |
| return b; | |
| } | |
| function addUnit(type, x, y) { | |
| const d = UNIT_TYPES[type]; | |
| const u = { id: nextId++, type, x, y, hp: d.hp, maxHp: d.hp, target: null, task: null, cargo: 0, cargoType: null, harvestTick: 0, selected: false }; | |
| units.push(u); | |
| population++; | |
| return u; | |
| } | |
| function initialState() { | |
| makeTerrain(); | |
| addBuilding('nexus', 3, 3); | |
| addBuilding('outpost', 7, 9); | |
| addUnit('worker', 354, 240); | |
| addUnit('worker', 365, 273); | |
| addUnit('worker', 334, 310); | |
| addUnit('scout', 389, 245); | |
| selected = [units[0]]; | |
| units[0].selected = true; | |
| camera.x = 0; camera.y = 0; | |
| } | |
| function resize() { | |
| const rect = field.getBoundingClientRect(); | |
| viewport.w = Math.max(1, rect.width); | |
| viewport.h = Math.max(1, rect.height); | |
| viewport.dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| canvas.width = Math.round(viewport.w * viewport.dpr); | |
| canvas.height = Math.round(viewport.h * viewport.dpr); | |
| ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0); | |
| constrainCamera(); | |
| } | |
| function constrainCamera() { | |
| camera.x = clamp(camera.x, 0, Math.max(0, WORLD_W - viewport.w)); | |
| camera.y = clamp(camera.y, 0, Math.max(0, WORLD_H - viewport.h)); | |
| } | |
| function setSelected(items) { | |
| for (const unit of units) unit.selected = false; | |
| selected = items; | |
| selectedBuilding = null; | |
| selected.forEach(u => u.selected = true); | |
| updatePanels(); | |
| } | |
| function selectBuilding(b) { | |
| for (const unit of units) unit.selected = false; | |
| selected = []; | |
| selectedBuilding = b; | |
| updatePanels(); | |
| } | |
| function updateResources() { | |
| document.getElementById('wood-count').textContent = Math.floor(resources.wood); | |
| document.getElementById('ore-count').textContent = Math.floor(resources.ore); | |
| document.getElementById('pop-count').textContent = `${population} / ${populationCap}`; | |
| for (const button of buildGrid.querySelectorAll('button')) { | |
| const cost = BUILD_COSTS[button.dataset.build]; | |
| button.disabled = !hasCost(cost); | |
| } | |
| } | |
| function updatePanels() { | |
| const selectedType = selected.length === 1 ? UNIT_TYPES[selected[0].type] : null; | |
| if (selectedBuilding) { | |
| const d = BUILDINGS[selectedBuilding.type]; | |
| selectionContent.innerHTML = `<div class="selection-name">${d.name}</div><div class="selection-subtitle">${selectedBuilding.queue.length ? `Production queue: ${selectedBuilding.queue.length}` : (selectedBuilding.progress < 1 ? 'Construction in progress' : 'Standing by')}</div><div class="meter"><span style="width:${Math.round(selectedBuilding.progress * 100)}%"></span></div>`; | |
| renderActions(selectedBuilding); | |
| } else if (selected.length > 1) { | |
| const full = selected.reduce((a, u) => a + u.hp / u.maxHp, 0) / selected.length; | |
| selectionContent.innerHTML = `<div class="selection-name">Field group</div><div class="selection-subtitle">${selected.length} units selected</div><div class="meter"><span style="width:${Math.round(full * 100)}%"></span></div>`; | |
| actionPanel.classList.remove('visible'); | |
| } else if (selectedType) { | |
| const unit = selected[0]; | |
| let job = selectedType.role === 'worker' ? 'Ready for harvest or construction.' : 'Ready for movement orders.'; | |
| if (unit.task === 'harvest') job = unit.cargo ? `Carrying ${unit.cargo} ${unit.cargoType}.` : 'Harvesting resource node.'; | |
| if (unit.task === 'build') job = 'Constructing field structure.'; | |
| selectionContent.innerHTML = `<div class="selection-name">${selectedType.name}</div><div class="selection-subtitle">${job}</div><div class="meter"><span style="width:${Math.round(unit.hp / unit.maxHp * 100)}%"></span></div>`; | |
| actionPanel.classList.remove('visible'); | |
| } else { | |
| selectionContent.innerHTML = '<div class="selection-name">No selection</div><div class="selection-subtitle">Select a unit or structure on the map.</div>'; | |
| actionPanel.classList.remove('visible'); | |
| } | |
| } | |
| function renderActions(b) { | |
| const d = BUILDINGS[b.type]; | |
| actionPanel.classList.toggle('visible', Boolean(d.production)); | |
| if (!d.production) return; | |
| actionGrid.innerHTML = d.production.map(type => { | |
| const u = UNIT_TYPES[type]; | |
| return `<button data-produce="${type}" ${(!hasCost(u.cost) || population >= populationCap || b.progress < 1) ? 'disabled' : ''} title="Train ${u.name}"><span class="cmd-icon">${type === 'worker' ? '◉' : type === 'scout' ? '➤' : '✚'}</span><span class="cmd-label">${u.name}</span><span class="cmd-cost">${u.cost.wood} timber · ${u.cost.ore} ore</span></button>`; | |
| }).join(''); | |
| } | |
| function setBuildMode(type) { | |
| if (!hasCost(BUILD_COSTS[type])) { toast('Insufficient materials.'); return; } | |
| buildMode = type; | |
| buildGrid.querySelectorAll('button').forEach(b => b.classList.toggle('armed', b.dataset.build === type)); | |
| status(`Place ${BUILDINGS[type].name} on cleared terrain.`); | |
| canvas.style.cursor = 'cell'; | |
| } | |
| function clearBuildMode() { | |
| buildMode = null; | |
| buildGrid.querySelectorAll('button').forEach(b => b.classList.remove('armed')); | |
| canvas.style.cursor = 'crosshair'; | |
| } | |
| function tileVisible(tx, ty) { return tx >= 0 && ty >= 0 && tx < MAP_W && ty < MAP_H && revealed[ty][tx]; } | |
| function buildingAt(x, y) { return buildings.find(b => x >= b.x && x <= b.x + b.w * TILE && y >= b.y && y <= b.y + b.h * TILE); } | |
| function nodeAt(x, y) { return nodes.find(n => Math.hypot(x - n.x, y - n.y) < n.radius + 8); } | |
| function unitAt(x, y) { | |
| return [...units].reverse().find(u => Math.hypot(x - u.x, y - u.y) < 17); | |
| } | |
| function validPlacement(type, tx, ty) { | |
| const d = BUILDINGS[type]; | |
| if (tx < 0 || ty < 0 || tx + d.w > MAP_W || ty + d.h > MAP_H) return false; | |
| for (let y = ty; y < ty + d.h; y++) for (let x = tx; x < tx + d.w; x++) if (!revealed[y][x]) return false; | |
| const x = tx * TILE, y = ty * TILE, w = d.w * TILE, h = d.h * TILE; | |
| if (buildings.some(b => x < b.x + b.w * TILE + 12 && x + w + 12 > b.x && y < b.y + b.h * TILE + 12 && y + h + 12 > b.y)) return false; | |
| return true; | |
| } | |
| function placementClear(type, tx, ty) { | |
| const d = BUILDINGS[type]; | |
| const x = tx * TILE, y = ty * TILE, w = d.w * TILE, h = d.h * TILE; | |
| if (!validPlacement(type, tx, ty)) return false; | |
| return !nodes.some(n => n.x > x - n.radius && n.x < x + w + n.radius && n.y > y - n.radius && n.y < y + h + n.radius); | |
| } | |
| function beginConstruction(type, wx, wy) { | |
| const tx = Math.floor(wx / TILE), ty = Math.floor(wy / TILE); | |
| if (!placementClear(type, tx, ty)) { toast('Build site blocked or hidden.'); return; } | |
| const worker = selected.find(u => UNIT_TYPES[u.type].role === 'worker') || units.find(u => UNIT_TYPES[u.type].role === 'worker' && !u.task); | |
| if (!worker) { toast('Select a free Field Engineer.'); return; } | |
| const cost = BUILD_COSTS[type]; | |
| if (!hasCost(cost)) { toast('Insufficient materials.'); clearBuildMode(); return; } | |
| pay(cost); | |
| const b = addBuilding(type, tx, ty, false); | |
| worker.task = 'build'; | |
| worker.target = centerOf(b); | |
| worker.building = b; | |
| selectedBuilding = b; | |
| clearBuildMode(); | |
| status(`${BUILDINGS[type].name} construction deployed.`); | |
| toast('Construction crew dispatched.'); | |
| updatePanels(); updateResources(); | |
| } | |
| function commandMove(target) { | |
| if (!selected.length) return; | |
| selected.forEach((u, i) => { | |
| u.task = 'move'; | |
| u.target = { x: clamp(target.x + (i % 3 - 1) * 26, 16, WORLD_W - 16), y: clamp(target.y + (Math.floor(i / 3) - 1) * 26, 16, WORLD_H - 16) }; | |
| u.cargo = 0; u.cargoType = null; | |
| }); | |
| status('Movement order acknowledged.'); | |
| updatePanels(); | |
| } | |
| function commandHarvest(node) { | |
| const workers = selected.filter(u => UNIT_TYPES[u.type].role === 'worker'); | |
| if (!workers.length) { commandMove(node); return; } | |
| workers.forEach((u, i) => { | |
| u.task = 'harvest'; | |
| u.target = { x: node.x + Math.cos(i * 2.1) * (node.radius - 8), y: node.y + Math.sin(i * 2.1) * (node.radius - 8) }; | |
| u.resource = node; | |
| u.cargo = 0; u.cargoType = null; | |
| }); | |
| status(`Harvesting ${node.type === 'wood' ? 'timber' : 'ore'} deposit.`); | |
| updatePanels(); | |
| } | |
| function enqueue(b, type) { | |
| const d = UNIT_TYPES[type]; | |
| if (!hasCost(d.cost) || population >= populationCap) { toast(population >= populationCap ? 'Build an Outpost for more capacity.' : 'Insufficient materials.'); return; } | |
| pay(d.cost); | |
| b.queue.push(type); | |
| status(`${d.name} queued.`); | |
| updateResources(); updatePanels(); | |
| } | |
| function moveUnit(u, dt) { | |
| if (!u.target) return true; | |
| const dx = u.target.x - u.x, dy = u.target.y - u.y; | |
| const len = Math.hypot(dx, dy); | |
| if (len < 3) { u.x = u.target.x; u.y = u.target.y; return true; } | |
| const amount = Math.min(len, UNIT_TYPES[u.type].speed * dt); | |
| u.x += dx / len * amount; | |
| u.y += dy / len * amount; | |
| return amount === len; | |
| } | |
| function nearbyDepot(u) { | |
| const candidates = buildings.filter(b => b.progress >= 1 && (b.type === 'nexus' || b.type === 'lumberyard' || b.type === 'refinery')); | |
| return candidates.sort((a, b) => distance(u, centerOf(a)) - distance(u, centerOf(b)))[0]; | |
| } | |
| function updateUnit(u, dt) { | |
| if (u.task === 'build') { | |
| if (!u.building || !buildings.includes(u.building)) { u.task = null; return; } | |
| if (!moveUnit(u, dt)) return; | |
| u.building.progress = Math.min(1, u.building.progress + dt / 4.5); | |
| if (u.building.progress >= 1) { | |
| const d = BUILDINGS[u.building.type]; | |
| if (d.supply) populationCap += d.supply; | |
| if (d.scan) { | |
| for (const row of revealed) row.fill(true); | |
| toast('Survey sweep complete: sector fully mapped.'); | |
| } | |
| status(`${d.name} complete.`); | |
| u.task = null; u.target = null; u.building = null; | |
| updatePanels(); updateResources(); | |
| } | |
| return; | |
| } | |
| if (u.task === 'harvest') { | |
| if (u.cargo > 0) { | |
| if (!u.target || !u.deposit) { u.deposit = nearbyDepot(u); u.target = centerOf(u.deposit); } | |
| if (moveUnit(u, dt)) { | |
| resources[u.cargoType] += u.cargo; | |
| u.cargo = 0; u.cargoType = null; u.deposit = null; | |
| u.target = { x: u.resource.x, y: u.resource.y }; | |
| updateResources(); | |
| } | |
| return; | |
| } | |
| if (!moveUnit(u, dt)) return; | |
| u.harvestTick += dt; | |
| if (u.harvestTick > .62) { | |
| u.harvestTick = 0; | |
| const amount = Math.min(12, u.resource.amount); | |
| if (!amount) { u.task = null; u.target = null; return; } | |
| u.resource.amount -= amount; | |
| u.cargo = amount; | |
| u.cargoType = u.resource.type; | |
| u.deposit = nearbyDepot(u); | |
| u.target = centerOf(u.deposit); | |
| } | |
| return; | |
| } | |
| if (u.target && moveUnit(u, dt)) { u.target = null; u.task = null; } | |
| } | |
| function updateBuildings(dt) { | |
| for (const b of buildings) { | |
| if (!b.queue.length || b.progress < 1) continue; | |
| b.production += dt; | |
| const type = b.queue[0]; | |
| if (b.production >= UNIT_TYPES[type].time) { | |
| b.production = 0; | |
| b.queue.shift(); | |
| const spawn = { x: b.x + b.w * TILE + 22, y: b.y + b.h * TILE / 2 + (Math.random() - .5) * 30 }; | |
| addUnit(type, clamp(spawn.x, 18, WORLD_W - 18), clamp(spawn.y, 18, WORLD_H - 18)); | |
| status(`${UNIT_TYPES[type].name} is ready.`); | |
| updateResources(); updatePanels(); | |
| } | |
| } | |
| } | |
| function updateVision() { | |
| const sources = [ | |
| ...units.map(u => ({ x: u.x, y: u.y, r: UNIT_TYPES[u.type].vision })), | |
| ...buildings.filter(b => b.progress > 0).map(b => ({ ...centerOf(b), r: BUILDINGS[b.type].vision * b.progress })) | |
| ]; | |
| for (const source of sources) { | |
| const tx = Math.floor(source.x / TILE), ty = Math.floor(source.y / TILE), r = Math.ceil(source.r); | |
| for (let y = ty - r; y <= ty + r; y++) for (let x = tx - r; x <= tx + r; x++) { | |
| if (x >= 0 && y >= 0 && x < MAP_W && y < MAP_H && Math.hypot(x - tx, y - ty) <= source.r) revealed[y][x] = true; | |
| } | |
| } | |
| } | |
| function updateCamera(dt) { | |
| const speed = 550; | |
| if (keys.has('arrowleft') || keys.has('a')) camera.x -= speed * dt; | |
| if (keys.has('arrowright') || keys.has('d')) camera.x += speed * dt; | |
| if (keys.has('arrowup') || keys.has('w')) camera.y -= speed * dt; | |
| if (keys.has('arrowdown') || keys.has('s')) camera.y += speed * dt; | |
| const edge = 18; | |
| if (mouse.x < edge) camera.x -= speed * dt; | |
| if (mouse.x > viewport.w - edge) camera.x += speed * dt; | |
| if (mouse.y < edge) camera.y -= speed * dt; | |
| if (mouse.y > viewport.h - edge) camera.y += speed * dt; | |
| constrainCamera(); | |
| } | |
| function drawTerrain() { | |
| const sx = Math.floor(camera.x / TILE), ex = Math.ceil((camera.x + viewport.w) / TILE); | |
| const sy = Math.floor(camera.y / TILE), ey = Math.ceil((camera.y + viewport.h) / TILE); | |
| ctx.fillStyle = '#1d4439'; | |
| ctx.fillRect(0, 0, viewport.w, viewport.h); | |
| for (let y = sy; y <= ey; y++) for (let x = sx; x <= ex; x++) { | |
| if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue; | |
| const px = x * TILE - camera.x, py = y * TILE - camera.y; | |
| const t = terrain[y][x]; | |
| ctx.fillStyle = t === 2 ? '#265646' : t === 1 ? '#234c40' : '#204638'; | |
| ctx.fillRect(px, py, TILE + 1, TILE + 1); | |
| const s = hash(x * 3, y * 9); | |
| ctx.fillStyle = s > .56 ? '#34634c' : '#173a32'; | |
| ctx.globalAlpha = .34; | |
| ctx.fillRect(px + 7 + s * 14, py + 9 + hash(y, x) * 16, 2, 2); | |
| ctx.fillRect(px + 31 - s * 9, py + 24 + hash(x, y + 5) * 11, 2, 2); | |
| ctx.globalAlpha = 1; | |
| if ((x + y) % 5 === 0) { ctx.strokeStyle = '#5f92763b'; ctx.lineWidth = 1; ctx.strokeRect(px, py, TILE, TILE); } | |
| } | |
| } | |
| function drawNodes() { | |
| for (const n of nodes) { | |
| if (n.amount <= 0) continue; | |
| const x = n.x - camera.x, y = n.y - camera.y; | |
| ctx.save(); | |
| ctx.translate(x, y); | |
| if (n.type === 'wood') { | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * 1.047 + .2; | |
| const px = Math.cos(a) * (9 + (i % 2) * 10), py = Math.sin(a) * (9 + (i % 3) * 7); | |
| ctx.fillStyle = '#132c25'; ctx.beginPath(); ctx.ellipse(px + 2, py + 8, 11, 8, 0, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = i % 2 ? '#477f50' : '#5e9560'; ctx.beginPath(); ctx.arc(px, py, 10, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = '#84b963'; ctx.beginPath(); ctx.arc(px - 3, py - 4, 4, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } else { | |
| ctx.shadowColor = '#58b9e0'; ctx.shadowBlur = 10; | |
| for (let i = 0; i < 6; i++) { | |
| const a = i * 1.047 + .3, r = 6 + (i % 3) * 8; | |
| const px = Math.cos(a) * r, py = Math.sin(a) * r; | |
| ctx.fillStyle = i % 2 ? '#5aaaca' : '#78d1dd'; | |
| ctx.beginPath(); ctx.moveTo(px, py - 13); ctx.lineTo(px + 7, py + 3); ctx.lineTo(px - 5, py + 10); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = '#b9f5e8'; ctx.lineWidth = 1; ctx.stroke(); | |
| } | |
| ctx.shadowBlur = 0; | |
| } | |
| ctx.restore(); | |
| } | |
| } | |
| function drawBuilding(b) { | |
| const d = BUILDINGS[b.type], x = b.x - camera.x, y = b.y - camera.y, w = b.w * TILE, h = b.h * TILE; | |
| ctx.save(); | |
| ctx.globalAlpha = .62 + b.progress * .38; | |
| ctx.fillStyle = '#0b171a'; ctx.fillRect(x + 5, y + 8, w, h); | |
| ctx.fillStyle = '#263d3f'; ctx.fillRect(x, y, w - 7, h - 8); | |
| ctx.strokeStyle = d.color; ctx.lineWidth = 2; ctx.strokeRect(x + 1, y + 1, w - 9, h - 10); | |
| ctx.fillStyle = '#182b30'; ctx.fillRect(x + 8, y + 9, w - 23, h - 25); | |
| ctx.fillStyle = d.color; ctx.globalAlpha *= .9; | |
| if (b.type === 'nexus') { | |
| ctx.beginPath(); ctx.moveTo(x + w / 2, y + 13); ctx.lineTo(x + w - 23, y + h - 20); ctx.lineTo(x + 22, y + h - 20); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#ccf8e4'; ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2 + 4, 12, 0, Math.PI * 2); ctx.fill(); | |
| } else if (b.type === 'beacon') { | |
| ctx.fillRect(x + w / 2 - 4, y + 15, 8, h - 29); ctx.beginPath(); ctx.arc(x + w / 2, y + 17, 11, 0, Math.PI * 2); ctx.fill(); | |
| ctx.strokeStyle = '#e7ccff'; ctx.beginPath(); ctx.arc(x + w / 2, y + 17, 18 + Math.sin(time * 2) * 2, 0, Math.PI * 2); ctx.stroke(); | |
| } else if (b.type === 'barracks') { | |
| ctx.fillRect(x + 17, y + 18, w - 42, h - 42); ctx.fillStyle = '#f0ac84'; ctx.fillRect(x + w / 2 - 5, y + 10, 10, h - 25); | |
| } else if (b.type === 'refinery') { | |
| ctx.beginPath(); ctx.arc(x + w / 2, y + h / 2, Math.min(w, h) * .25, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#c0f0e8'; ctx.fillRect(x + 12, y + 13, w - 35, 6); | |
| } else { | |
| ctx.fillRect(x + 14, y + h / 2 - 8, w - 35, 17); ctx.fillStyle = '#dce9b0'; ctx.fillRect(x + w / 2 - 4, y + 13, 8, h - 28); | |
| } | |
| ctx.globalAlpha = 1; | |
| if (b.progress < 1) { | |
| ctx.fillStyle = '#081114cc'; ctx.fillRect(x, y + h - 8, w - 7, 8); | |
| ctx.fillStyle = '#efbd64'; ctx.fillRect(x, y + h - 8, (w - 7) * b.progress, 8); | |
| } | |
| if (selectedBuilding === b) { | |
| ctx.strokeStyle = '#e7f4cf'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.strokeRect(x - 4, y - 4, w + 1, h); ctx.setLineDash([]); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawUnits() { | |
| for (const u of units) { | |
| const d = UNIT_TYPES[u.type], x = u.x - camera.x, y = u.y - camera.y; | |
| ctx.save(); ctx.translate(x, y); | |
| ctx.fillStyle = '#0a1517aa'; ctx.beginPath(); ctx.ellipse(3, 8, 13, 6, 0, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = d.color; | |
| if (u.type === 'scout') { | |
| ctx.beginPath(); ctx.moveTo(0, -11); ctx.lineTo(13, 9); ctx.lineTo(-13, 9); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#e3f3cf'; ctx.fillRect(-2, -4, 4, 8); | |
| } else { | |
| ctx.beginPath(); ctx.arc(0, -4, 7, Math.PI, 0); ctx.lineTo(10, 9); ctx.lineTo(-10, 9); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#e7f2ce'; ctx.fillRect(-4, -9, 8, 6); | |
| if (u.type === 'guard') { ctx.strokeStyle = '#f1dca6'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(5, -1); ctx.lineTo(15, -8); ctx.stroke(); } | |
| if (u.type === 'worker') { ctx.strokeStyle = '#d7f4d5'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-6, 1); ctx.lineTo(-13, -7); ctx.stroke(); } | |
| } | |
| if (u.cargo) { ctx.fillStyle = u.cargoType === 'wood' ? '#79b867' : '#78d8e2'; ctx.fillRect(-12, 7, 5, 5); } | |
| if (u.selected) { ctx.strokeStyle = '#e7f4cf'; ctx.lineWidth = 2; ctx.beginPath(); ctx.ellipse(0, 8, 16, 8, 0, 0, Math.PI * 2); ctx.stroke(); } | |
| ctx.restore(); | |
| } | |
| } | |
| function drawOrders() { | |
| for (const u of selected) if (u.target) { | |
| const x = u.target.x - camera.x, y = u.target.y - camera.y; | |
| ctx.strokeStyle = '#baf2d4aa'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(u.x - camera.x, u.y - camera.y); ctx.lineTo(x, y); ctx.stroke(); ctx.setLineDash([]); | |
| ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI * 2); ctx.stroke(); | |
| } | |
| } | |
| function drawFog() { | |
| const sx = Math.floor(camera.x / TILE), ex = Math.ceil((camera.x + viewport.w) / TILE); | |
| const sy = Math.floor(camera.y / TILE), ey = Math.ceil((camera.y + viewport.h) / TILE); | |
| for (let y = sy; y <= ey; y++) for (let x = sx; x <= ex; x++) { | |
| if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || revealed[y][x]) continue; | |
| const px = x * TILE - camera.x, py = y * TILE - camera.y; | |
| ctx.fillStyle = '#071014e8'; ctx.fillRect(px, py, TILE + 1, TILE + 1); | |
| ctx.strokeStyle = '#2b4b4f75'; ctx.lineWidth = 1; ctx.strokeRect(px, py, TILE, TILE); | |
| } | |
| } | |
| function drawPlacementGhost() { | |
| if (!buildMode) return; | |
| const tx = Math.floor(mouse.worldX / TILE), ty = Math.floor(mouse.worldY / TILE), d = BUILDINGS[buildMode]; | |
| const ok = placementClear(buildMode, tx, ty); | |
| ctx.save(); ctx.globalAlpha = .55; ctx.fillStyle = ok ? '#65d6ac' : '#e36755'; | |
| ctx.fillRect(tx * TILE - camera.x, ty * TILE - camera.y, d.w * TILE, d.h * TILE); | |
| ctx.strokeStyle = ok ? '#d7ffe8' : '#ffd6cb'; ctx.lineWidth = 2; ctx.strokeRect(tx * TILE - camera.x + 1, ty * TILE - camera.y + 1, d.w * TILE - 2, d.h * TILE - 2); | |
| ctx.restore(); | |
| } | |
| function renderMini() { | |
| const w = mini.width, h = mini.height, sx = w / WORLD_W, sy = h / WORLD_H; | |
| mctx.fillStyle = '#1c4038'; mctx.fillRect(0, 0, w, h); | |
| for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) { | |
| const px = x * TILE * sx, py = y * TILE * sy; | |
| mctx.fillStyle = revealed[y][x] ? (terrain[y][x] === 2 ? '#397451' : '#31664a') : '#0c171b'; | |
| mctx.fillRect(px, py, Math.ceil(TILE * sx), Math.ceil(TILE * sy)); | |
| } | |
| for (const n of nodes) { mctx.fillStyle = n.type === 'wood' ? '#7baa59' : '#65b9d6'; mctx.fillRect(n.x * sx - 2, n.y * sy - 2, 4, 4); } | |
| for (const b of buildings) { mctx.fillStyle = BUILDINGS[b.type].color; mctx.fillRect(b.x * sx, b.y * sy, b.w * TILE * sx, b.h * TILE * sy); } | |
| for (const u of units) { mctx.fillStyle = '#eaf6d2'; mctx.fillRect(u.x * sx - 1, u.y * sy - 1, 3, 3); } | |
| mctx.strokeStyle = '#e8f2dc'; mctx.lineWidth = 1; mctx.strokeRect(camera.x * sx, camera.y * sy, viewport.w * sx, viewport.h * sy); | |
| const seen = revealed.flat().filter(Boolean).length; | |
| document.getElementById('map-progress').textContent = `${Math.round(seen / (MAP_W * MAP_H) * 100)}%`; | |
| } | |
| function render() { | |
| ctx.clearRect(0, 0, viewport.w, viewport.h); | |
| drawTerrain(); drawNodes(); buildings.forEach(drawBuilding); drawOrders(); drawUnits(); drawFog(); drawPlacementGhost(); renderMini(); | |
| } | |
| function tick(now) { | |
| const dt = Math.min(.05, (now - last) / 1000); last = now; time += dt; | |
| updateCamera(dt); | |
| units.forEach(u => updateUnit(u, dt)); | |
| updateBuildings(dt); | |
| updateVision(); | |
| render(); | |
| requestAnimationFrame(tick); | |
| } | |
| function clickMap(world) { | |
| if (buildMode) { beginConstruction(buildMode, world.x, world.y); return; } | |
| const b = buildingAt(world.x, world.y); | |
| const u = unitAt(world.x, world.y); | |
| const n = nodeAt(world.x, world.y); | |
| if (b) { selectBuilding(b); status(`${BUILDINGS[b.type].name} selected.`); return; } | |
| if (u) { setSelected([u]); status(`${UNIT_TYPES[u.type].name} selected.`); return; } | |
| if (n) { commandHarvest(n); return; } | |
| commandMove(world); | |
| } | |
| function finishDrag() { | |
| const x1 = Math.min(mouse.startX, mouse.x), x2 = Math.max(mouse.startX, mouse.x), y1 = Math.min(mouse.startY, mouse.y), y2 = Math.max(mouse.startY, mouse.y); | |
| const items = units.filter(u => { | |
| const x = u.x - camera.x, y = u.y - camera.y; | |
| return x >= x1 && x <= x2 && y >= y1 && y <= y2; | |
| }); | |
| if (items.length) { setSelected(items); status(`${items.length} units selected.`); } | |
| } | |
| canvas.addEventListener('mousedown', e => { | |
| if (e.button !== 0) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const p = worldPoint(e); | |
| mouse.down = true; mouse.startX = e.clientX - rect.left; mouse.startY = e.clientY - rect.top; | |
| mouse.x = mouse.startX; mouse.y = mouse.startY; mouse.worldX = p.x; mouse.worldY = p.y; | |
| }); | |
| window.addEventListener('mouseup', e => { | |
| if (!mouse.down || e.button !== 0) return; | |
| mouse.down = false; selectionBox.style.display = 'none'; | |
| const p = worldPoint(e); | |
| if (Math.hypot(mouse.x - mouse.startX, mouse.y - mouse.startY) > 9 && !buildMode) finishDrag(); else clickMap(p); | |
| }); | |
| canvas.addEventListener('mousemove', e => { | |
| const rect = canvas.getBoundingClientRect(), p = worldPoint(e); | |
| mouse.x = e.clientX - rect.left; mouse.y = e.clientY - rect.top; mouse.worldX = p.x; mouse.worldY = p.y; | |
| if (mouse.down && !buildMode) { | |
| const left = Math.min(mouse.startX, mouse.x), top = Math.min(mouse.startY, mouse.y); | |
| selectionBox.style.display = 'block'; selectionBox.style.left = `${left}px`; selectionBox.style.top = `${top}px`; | |
| selectionBox.style.width = `${Math.abs(mouse.x - mouse.startX)}px`; selectionBox.style.height = `${Math.abs(mouse.y - mouse.startY)}px`; | |
| } | |
| }); | |
| canvas.addEventListener('mouseleave', () => { mouse.x = -100; mouse.y = -100; }); | |
| canvas.addEventListener('contextmenu', e => { e.preventDefault(); if (buildMode) { clearBuildMode(); status('Construction order cancelled.'); return; } commandMove(worldPoint(e)); }); | |
| window.addEventListener('keydown', e => { | |
| const key = e.key.toLowerCase(); | |
| if (['arrowleft', 'arrowright', 'arrowup', 'arrowdown', 'w', 'a', 's', 'd'].includes(key)) { keys.add(key); e.preventDefault(); } | |
| if (key === 'escape') { clearBuildMode(); setSelected([]); status('Selection cleared.'); } | |
| }); | |
| window.addEventListener('keyup', e => keys.delete(e.key.toLowerCase())); | |
| buildGrid.addEventListener('click', e => { const button = e.target.closest('[data-build]'); if (button) setBuildMode(button.dataset.build); }); | |
| actionGrid.addEventListener('click', e => { const button = e.target.closest('[data-produce]'); if (button && selectedBuilding) enqueue(selectedBuilding, button.dataset.produce); }); | |
| mini.addEventListener('click', e => { | |
| const rect = mini.getBoundingClientRect(); | |
| const target = { x: (e.clientX - rect.left) / rect.width * WORLD_W, y: (e.clientY - rect.top) / rect.height * WORLD_H }; | |
| if (selected.length) commandMove(target); else { camera.x = target.x - viewport.w / 2; camera.y = target.y - viewport.h / 2; constrainCamera(); } | |
| }); | |
| window.addEventListener('resize', resize); | |
| initialState(); resize(); updateResources(); updatePanels(); updateVision(); | |
| status('Expand the frontier and map the sector.'); | |
| requestAnimationFrame(tick); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment