source
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skill Tree Idle</title>
<style>
:root {
--bg: #0e1118;
--panel: #161c28;
--text: #e8ecf4;
--muted: #8892a8;
--minor: #4ecdc4;
--minor-glow: rgba(78, 205, 196, 0.45);
--major: #ff6b9d;
--major-glow: rgba(255, 107, 157, 0.4);
--edge: #2a3548;
--accent: #ffd166;
--danger: #ff5c5c;
--tooltip-bg: #222a38;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
font-family: "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
user-select: none;
}
#hud {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.25rem;
padding: 0.75rem 1.25rem;
background: var(--panel);
border-bottom: 1px solid #252d3d;
box-shadow: var(--shadow);
z-index: 100;
position: relative;
}
#hud strong { color: var(--accent); }
#hud .stat { font-size: 0.95rem; }
#game-wrap {
position: relative;
height: calc(100vh - 56px);
overflow: hidden;
background:
radial-gradient(ellipse 120% 80% at 50% 100%, #151b2e 0%, var(--bg) 55%);
}
#game-canvas {
display: block;
width: 100%;
height: 100%;
cursor: default;
touch-action: none;
}
#tooltip {
position: fixed;
z-index: 1000;
pointer-events: none;
opacity: 0;
transition: opacity 0.08s;
max-width: min(320px, calc(100vw - 24px));
}
#tooltip.visible { opacity: 1; }
#tooltip .inner {
background: var(--tooltip-bg);
border: 1px solid #3d4a5f;
border-radius: 10px;
padding: 10px 12px;
box-shadow: var(--shadow);
font-size: 0.85rem;
line-height: 1.45;
}
#tooltip .title {
font-weight: 700;
margin-bottom: 6px;
color: var(--accent);
}
#tooltip .row { color: var(--muted); margin-top: 4px; font-size: 0.8rem; }
#tooltip .row strong { color: var(--text); }
#tooltip .tooltip-cost { color: var(--accent); font-weight: 700; }
</style>
</head>
<body>
<header id="hud">
<span class="stat">Skill points: <strong id="sp-display">0.0</strong></span>
<span class="stat">Income: <strong id="rate-display">0.00</strong> / sec</span>
<span class="stat">Income mult: <strong id="mult-display">×1.00</strong></span>
<span class="stat">Minor cap: <strong id="cap-minor-display">1</strong></span>
<span class="stat">Major cap: <strong id="cap-major-display">1</strong></span>
</header>
<div id="game-wrap">
<canvas id="game-canvas" aria-label="Skill tree"></canvas>
</div>
<div id="tooltip"><div class="inner"></div></div>
<script>
(function () {
"use strict";
/** Tuning — documented */
var BASE_INCOME = 0.1;
var PULSE_INTERVAL_MS = 1000;
var LEVEL_INCOME_ADD = 0.1;
/** Each level on a globalMult major multiplies income by this factor (compound per major node). */
var GLOBAL_MULT_PER_LEVEL = 1.1;
/** descendantMult: each level adds +100% additive on that major (L→×(1+L), not ×2^L). Multiple Br ancestors multiply (1+each.level). */
/** Every parent→child edge: +this × descendant weight under child (rest & spawn; see descendantWeightForLayout). */
var EDGE_LENGTH_SCALE_PER_DESCENDANT = 0.04;
/** Soft cap on descendant count for length/angle scaling (avoids runaway on huge trees). */
var LAYOUT_DESCENDANT_WEIGHT_CAP = 48;
/** Half-fan angle (deg) += this × weight; capped by FAN_ANGLE_DEG_MAX. */
var FAN_ANGLE_EXTRA_PER_DESCENDANT = 0.35;
var FAN_ANGLE_DEG_MAX = 52;
/** Starting currency; first node costs this much (see unlockCostForDepth(0)). */
var STARTING_SKILL_POINTS = 1;
var NODE_R_MINOR = 22;
var NODE_R_MAJOR = 27;
var FLOATY_DURATION_MS = 1100;
var floaties = [];
var state = {
skillPoints: 0,
nodes: [],
nextId: 1,
/** Stable Y anchor for root; set on first layout seed. */
rootPinY: null,
};
var camera = {
x: 0,
y: 0,
zoom: 1,
initialized: false,
};
var keysDown = {};
var panDrag = null;
var hoverNodeId = null;
var pulseUntil = {};
var lastFrameMs = performance.now();
var ctx = null;
var els = {
gameWrap: document.getElementById("game-wrap"),
canvas: document.getElementById("game-canvas"),
spDisplay: document.getElementById("sp-display"),
rateDisplay: document.getElementById("rate-display"),
multDisplay: document.getElementById("mult-display"),
capMinor: document.getElementById("cap-minor-display"),
capMajor: document.getElementById("cap-major-display"),
tooltip: document.getElementById("tooltip"),
tooltipInner: document.querySelector("#tooltip .inner"),
};
function rng() { return Math.random(); }
function getMaxMinorCap() {
var b = 1;
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
if (n.owned && n.type === "major" && n.subtype === "raiseMinorCap") b += n.level;
}
return b;
}
function getMaxMajorCap() {
var b = 1;
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
if (n.owned && n.type === "major" && n.subtype === "raiseMajorCap") b += n.level;
}
return b;
}
function recomputeGlobalIncomeMult() {
var m = 1;
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
if (n.owned && n.type === "major" && n.subtype === "globalMult") {
m *= Math.pow(GLOBAL_MULT_PER_LEVEL, n.level);
}
}
return m;
}
function getGlobalIncomeMult() {
return recomputeGlobalIncomeMult();
}
/** Extra multiplier from ancestor descendantMult majors (strict descendants only; per ancestor ×(1+level), additive % per level). */
function getDescendantBranchIncomeMult(node) {
var m = 1;
var cur = getNode(node.parentId);
while (cur) {
if (cur.owned && cur.type === "major" && cur.subtype === "descendantMult") {
m *= 1 + cur.level;
}
cur = getNode(cur.parentId);
}
return m;
}
/** Base SP/s from one minor income node at its current level (before global mult). */
function minorIncomeBasePerSec(node) {
return BASE_INCOME + (node.level - 1) * LEVEL_INCOME_ADD;
}
function minorIncomeBasePerSecAtLevel(level) {
return BASE_INCOME + (level - 1) * LEVEL_INCOME_ADD;
}
/** Per-node income pulse amount (one second worth at current mult) for minor income. */
function incomePulseAmount(node) {
if (!node.owned || node.type !== "minor" || node.subtype !== "income") return 0;
var mult = getGlobalIncomeMult() * getDescendantBranchIncomeMult(node);
return minorIncomeBasePerSec(node) * mult;
}
function totalPassiveIncomePerSec() {
var sum = 0;
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
if (n.owned && n.type === "minor" && n.subtype === "income") {
sum += minorIncomeBasePerSec(n) * getGlobalIncomeMult() * getDescendantBranchIncomeMult(n);
}
}
return sum;
}
/** Tree depth 0 = first buy (STARTING_SKILL_POINTS); each deeper row doubles (1, 2, 4, 8, …). */
function unlockCostForDepth(depth) {
if (depth <= 0) return STARTING_SKILL_POINTS;
return Math.max(1, Math.floor(STARTING_SKILL_POINTS * Math.pow(2, depth)));
}
/**
* Cost to go from level L → L+1: ceil(unlock + unlock/2 × 2^L).
* Unlock is the depth-priced buy-in; upgrades scale up from that with no separate depth curve.
*/
function nextLevelCost(node) {
var cap = node.type === "minor" ? getMaxMinorCap() : getMaxMajorCap();
if (node.level >= cap) return null;
var u = node.unlockCost;
return Math.ceil(u + (u / 2) * Math.pow(2, node.level));
}
function pickChildCount() {
return 2;
}
function pickMajorChance(depth) {
return Math.min(0.32, 0.12 + depth * 0.035);
}
function randomMinorSubtype() {
return "income";
}
function isMajorSubtypeTaken(subtype) {
for (var i = 0; i < state.nodes.length; i++) {
var nn = state.nodes[i];
if (nn.type === "major" && nn.subtype === subtype) return true;
}
return false;
}
/** Picks a major subtype not yet present in the tree, or null if all major types exist. */
function pickUnusedMajorSubtype() {
var opts = [
{ subtype: "globalMult", w: 0.32 },
{ subtype: "descendantMult", w: 0.28 },
{ subtype: "raiseMinorCap", w: 0.2 },
{ subtype: "raiseMajorCap", w: 0.2 },
];
var pool = [];
var total = 0;
for (var i = 0; i < opts.length; i++) {
if (!isMajorSubtypeTaken(opts[i].subtype)) {
pool.push(opts[i]);
total += opts[i].w;
}
}
if (!pool.length) return null;
var r = rng() * total;
for (var j = 0; j < pool.length; j++) {
r -= pool[j].w;
if (r < 0) return pool[j].subtype;
}
return pool[pool.length - 1].subtype;
}
function nodeLabel(n) {
if (n.type === "minor" && n.subtype === "income") return "INC";
if (n.subtype === "globalMult") return "%";
if (n.subtype === "descendantMult") return "Br";
if (n.subtype === "raiseMinorCap") return "Mi+";
if (n.subtype === "raiseMajorCap") return "Ma+";
return "?";
}
function nodeTitle(n) {
if (n.type === "minor" && n.subtype === "income") return "Minor — Passive income";
if (n.subtype === "globalMult") return "Major — Global income +10%/lvl";
if (n.subtype === "descendantMult") return "Major — Subtree income +100%/lvl";
if (n.subtype === "raiseMinorCap") return "Major — +minor level cap / lvl";
if (n.subtype === "raiseMajorCap") return "Major — +major level cap / lvl";
return "Node";
}
function nodeEffectLine(n) {
return nodeEffectLineAtLevel(n, n.level);
}
function nodeEffectLineAtLevel(n, level) {
if (n.type === "minor" && n.subtype === "income") {
var amt =
minorIncomeBasePerSecAtLevel(level) *
getGlobalIncomeMult() *
getDescendantBranchIncomeMult(n);
return "Contributes +" + amt.toFixed(3) + " SP/s (after mult)";
}
if (n.subtype === "globalMult") {
return "Multiplies all income by " + Math.pow(GLOBAL_MULT_PER_LEVEL, level).toFixed(4) + " (this node)";
}
if (n.subtype === "descendantMult") {
return (
"Multiplies income from descendants by " +
(1 + level).toFixed(0) +
" (+100% additive per lvl on this major; stacks with other Br majors)"
);
}
if (n.subtype === "raiseMinorCap") return "Global max minor level +" + level;
if (n.subtype === "raiseMajorCap") return "Global max major level +" + level;
return "";
}
function tooltipCostHtml(amount) {
return '<span class="tooltip-cost">' + amount + " SP</span>";
}
function createNode(opts) {
var n = {
id: opts.id != null ? opts.id : state.nextId++,
type: opts.type,
subtype: opts.subtype,
depth: opts.depth,
parentId: opts.parentId,
childIds: [],
x: 0,
y: 0,
owned: !!opts.owned,
childrenSpawned: !!opts.childrenSpawned,
level: opts.level != null ? opts.level : 1,
unlockCost: opts.unlockCost != null ? opts.unlockCost : unlockCostForDepth(opts.depth),
lastPulseMs: typeof opts.lastPulseMs === "number" ? opts.lastPulseMs : performance.now() + rng() * PULSE_INTERVAL_MS,
layoutPlaced: !!opts.layoutPlaced,
};
if (opts.id != null && opts.id >= state.nextId) state.nextId = opts.id + 1;
return n;
}
function initRoot() {
state.nodes = [];
state.nextId = 1;
state.skillPoints = STARTING_SKILL_POINTS;
var root = createNode({
id: 0,
type: "minor",
subtype: "income",
depth: 0,
parentId: null,
owned: false,
childrenSpawned: false,
level: 1,
unlockCost: unlockCostForDepth(0),
layoutPlaced: false,
});
state.nodes.push(root);
state.rootPinY = null;
}
function getNode(id) {
for (var i = 0; i < state.nodes.length; i++) {
if (state.nodes[i].id === id) return state.nodes[i];
}
return null;
}
function spawnChildren(parent) {
if (parent.childrenSpawned) return;
var count = pickChildCount(parent.depth);
var majorChance = pickMajorChance(parent.depth);
for (var i = 0; i < count; i++) {
var isMajor = rng() < majorChance;
var type, subtype;
if (isMajor) {
var majSub = pickUnusedMajorSubtype();
if (majSub) {
type = "major";
subtype = majSub;
} else {
type = "minor";
subtype = randomMinorSubtype();
}
} else {
type = "minor";
subtype = randomMinorSubtype();
}
var depth = parent.depth + 1;
var child = createNode({
type: type,
subtype: subtype,
depth: depth,
parentId: parent.id,
owned: false,
childrenSpawned: false,
level: 1,
unlockCost: unlockCostForDepth(depth),
layoutPlaced: false,
});
state.nodes.push(child);
parent.childIds.push(child.id);
seedChildFanPosition(parent, child, i);
}
parent.childrenSpawned = true;
}
/** Position-based layout tuning (continuous relaxation in tick). */
var layout = {
/** Target edge length once the spring constraint has relaxed. */
restLength: 110,
/** Initial placement: near parent along ±angle (then springs ease out to restLength). */
spawnEdgeLength: 28,
repelRadius: 92,
repelSoftness: 0.16,
/** Repel nodes from tree line segments (parent–child). */
lineRepelMargin: 62,
lineRepelSoftness: 0.14,
/** Blend directed half-plane correction vs radial push when sibling-side is wrong (0 = all radial). */
lineRepelHalfPlaneBlend: 0.62,
/** Base half-fan angle (deg); actual per edge adds weight from child subtree. */
angleDeg: 30,
/** 0 = full parent-chain direction, 1 = full screen-up; 0.5 = half and half. */
fanBlendTrueUp: 0.5,
substep: 0.16,
subItersPerFrame: 1,
distanceStiffness: 0.2,
maxDistanceCorr: 4.2,
/** Soft PBD: pull parent→child direction toward the same per-edge half-fan as initial spawn. */
angleStiffness: 0.1,
maxAngleCorr: 3.5,
maxRepelStep: 7,
boundsPad: 96,
};
/** Strict descendants under each node (excluding node). Cached in `_descBelow` by refresh. */
function refreshSubtreeDescendantCounts() {
function visit(n) {
var d = 0;
for (var i = 0; i < n.childIds.length; i++) {
var ch = getNode(n.childIds[i]);
if (ch) d += 1 + visit(ch);
}
n._descBelow = d;
return d;
}
var root = getNode(0);
if (root) visit(root);
}
function descendantWeightForLayout(d) {
if (typeof d !== "number" || d < 0) return 0;
return d > LAYOUT_DESCENDANT_WEIGHT_CAP ? LAYOUT_DESCENDANT_WEIGHT_CAP : d;
}
function edgeRestLengthForEdge(parent, child) {
var w = descendantWeightForLayout(typeof child._descBelow === "number" ? child._descBelow : 0);
return layout.restLength * (1 + EDGE_LENGTH_SCALE_PER_DESCENDANT * w);
}
function edgeSpawnLengthForEdge(parent, child) {
var w = descendantWeightForLayout(typeof child._descBelow === "number" ? child._descBelow : 0);
return layout.spawnEdgeLength * (1 + EDGE_LENGTH_SCALE_PER_DESCENDANT * w);
}
function halfFanAngleDegForEdge(parent, child) {
var w = descendantWeightForLayout(typeof child._descBelow === "number" ? child._descBelow : 0);
var deg = layout.angleDeg + FAN_ANGLE_EXTRA_PER_DESCENDANT * w;
if (deg > FAN_ANGLE_DEG_MAX) deg = FAN_ANGLE_DEG_MAX;
return deg;
}
function halfFanAngleRadForEdge(parent, child) {
return halfFanAngleDegForEdge(parent, child) * (Math.PI / 180);
}
function rawNodeBounds() {
var minX = Infinity;
var maxX = -Infinity;
var minY = Infinity;
var maxY = -Infinity;
var pad = layout.boundsPad;
for (var i = 0; i < state.nodes.length; i++) {
var nd = state.nodes[i];
minX = Math.min(minX, nd.x);
maxX = Math.max(maxX, nd.x);
minY = Math.min(minY, nd.y);
maxY = Math.max(maxY, nd.y);
}
if (!state.nodes.length || !isFinite(minX)) return null;
return {
minX: minX - pad,
maxX: maxX + pad,
minY: minY - pad,
maxY: maxY + pad,
};
}
function getGameMetrics() {
var viewW = els.canvas.clientWidth || 800;
var viewH = els.canvas.clientHeight || 600;
if (viewW < 400) viewW = 800;
var b = rawNodeBounds();
var spanX = b ? b.maxX - b.minX + 140 : viewW;
var spanY = b ? b.maxY - b.minY + 130 : viewH;
var gameW = Math.max(viewW, spanX);
var gameH = Math.max(viewH, spanY);
return {
gameW: gameW,
gameH: gameH,
centerX: gameW / 2,
bottomY: gameH - 70,
viewW: viewW,
viewH: viewH,
};
}
var FAN_TRUE_UP_X = 0;
var FAN_TRUE_UP_Y = -1;
/** Blended fan base: mix parent→grandparent unit direction with screen-up (root’s reference). */
function fanBaseUnit(parent) {
var chainX = FAN_TRUE_UP_X;
var chainY = FAN_TRUE_UP_Y;
var gp = parent.parentId != null ? getNode(parent.parentId) : null;
if (gp) {
var sx = parent.x - gp.x;
var sy = parent.y - gp.y;
var slen = Math.hypot(sx, sy);
if (slen >= 1e-6) {
chainX = sx / slen;
chainY = sy / slen;
}
}
var w = layout.fanBlendTrueUp;
if (w <= 0) return { x: chainX, y: chainY };
if (w >= 1) return { x: FAN_TRUE_UP_X, y: FAN_TRUE_UP_Y };
var mx = (1 - w) * chainX + w * FAN_TRUE_UP_X;
var my = (1 - w) * chainY + w * FAN_TRUE_UP_Y;
var mlen = Math.hypot(mx, my);
if (mlen < 1e-6) return { x: FAN_TRUE_UP_X, y: FAN_TRUE_UP_Y };
return { x: mx / mlen, y: my / mlen };
}
function seedChildFanPosition(parent, child, childIndex) {
var u = fanBaseUnit(parent);
var baseAng = Math.atan2(u.y, u.x);
var off = (childIndex === 0 ? -1 : 1) * halfFanAngleRadForEdge(parent, child);
var ang = baseAng + off;
var dx = Math.cos(ang);
var dy = Math.sin(ang);
var spawnLen = edgeSpawnLengthForEdge(parent, child);
child.x = parent.x + spawnLen * dx;
child.y = parent.y + spawnLen * dy;
child.layoutPlaced = true;
}
function rootPinY(metrics) {
return state.rootPinY != null ? state.rootPinY : metrics.bottomY;
}
function ensureRootSeeded(metrics) {
var root = getNode(0);
if (!root || root.layoutPlaced) return;
root.x = metrics.centerX;
if (state.rootPinY == null) state.rootPinY = metrics.bottomY;
root.y = state.rootPinY;
root.layoutPlaced = true;
}
function updateScrollBounds() {
/* Virtual extent is folded into getGameMetrics(); canvas is fixed to the viewport. */
}
function constrainEdgeLengths(metrics) {
var alpha = layout.substep * layout.distanceStiffness;
for (var i = 0; i < state.nodes.length; i++) {
var c = state.nodes[i];
var p = getNode(c.parentId);
if (!p) continue;
var dx = c.x - p.x;
var dy = c.y - p.y;
var dist = Math.hypot(dx, dy);
if (dist < 1e-6) continue;
var nx = dx / dist;
var ny = dy / dist;
var err = dist - edgeRestLengthForEdge(p, c);
var corr = alpha * err;
var cap = layout.maxDistanceCorr;
if (corr > cap) corr = cap;
if (corr < -cap) corr = -cap;
var hx = nx * corr * 0.5;
var hy = ny * corr * 0.5;
if (p.id === 0) {
c.x -= nx * corr;
c.y -= ny * corr;
} else {
p.x += hx;
p.y += hy;
c.x -= hx;
c.y -= hy;
}
}
var root = getNode(0);
if (root) {
root.x = metrics.centerX;
root.y = rootPinY(metrics);
}
}
function constrainFanAngles(metrics) {
var alpha = layout.substep * layout.angleStiffness;
var cap = layout.maxAngleCorr;
for (var i = 0; i < state.nodes.length; i++) {
var c = state.nodes[i];
var p = getNode(c.parentId);
if (!p) continue;
var u = fanBaseUnit(p);
var baseAng = Math.atan2(u.y, u.x);
var sibs = p.childIds;
var idx = 0;
for (var si = 0; si < sibs.length; si++) {
if (sibs[si] === c.id) {
idx = si;
break;
}
}
var off = (idx === 0 ? -1 : 1) * halfFanAngleRadForEdge(p, c);
var tx = Math.cos(baseAng + off);
var ty = Math.sin(baseAng + off);
var dx = c.x - p.x;
var dy = c.y - p.y;
var dist = Math.hypot(dx, dy);
if (dist < 1e-6) continue;
var cxStar = p.x + dist * tx;
var cyStar = p.y + dist * ty;
var dcx = cxStar - c.x;
var dcy = cyStar - c.y;
var mag = Math.hypot(dcx, dcy);
if (mag < 1e-9) continue;
var corr = alpha * mag;
if (corr > cap) corr = cap;
var nx = dcx / mag;
var ny = dcy / mag;
var stepx = nx * corr;
var stepy = ny * corr;
if (p.id === 0) {
c.x += stepx;
c.y += stepy;
} else {
var hx = stepx * 0.5;
var hy = stepy * 0.5;
p.x -= hx;
p.y -= hy;
c.x += hx;
c.y += hy;
}
}
var root = getNode(0);
if (root) {
root.x = metrics.centerX;
root.y = rootPinY(metrics);
}
}
function applyNodeRepulsion() {
var nList = state.nodes;
var rad = layout.repelRadius;
var base = layout.repelSoftness * layout.substep;
var maxS = layout.maxRepelStep;
for (var i = 0; i < nList.length; i++) {
for (var j = i + 1; j < nList.length; j++) {
var a = nList[i];
var b = nList[j];
var dx = b.x - a.x;
var dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
if (dist >= rad || dist < 1e-6) continue;
var nx = dx / dist;
var ny = dy / dist;
var penetration = rad - dist;
var mag = base * penetration;
if (mag > maxS) mag = maxS;
if (a.id === 0) {
b.x += nx * mag;
b.y += ny * mag;
} else if (b.id === 0) {
a.x -= nx * mag;
a.y -= ny * mag;
} else {
a.x -= nx * mag * 0.5;
a.y -= ny * mag * 0.5;
b.x += nx * mag * 0.5;
b.y += ny * mag * 0.5;
}
}
}
}
/** Direct child of ancestor on the path from ancestor down to node, or null if node is not under ancestor. */
function spineChildOfParent(node, ancestor) {
if (!node || !ancestor || node.id === ancestor.id) return null;
var cur = node;
while (cur.parentId != null) {
if (cur.parentId === ancestor.id) return cur;
cur = getNode(cur.parentId);
if (!cur) return null;
}
return null;
}
function applyLineSegmentRepulsion() {
var mar = layout.lineRepelMargin;
var base = layout.lineRepelSoftness * layout.substep;
var maxS = layout.maxRepelStep;
var halfBlend = layout.lineRepelHalfPlaneBlend;
if (halfBlend < 0) halfBlend = 0;
else if (halfBlend > 1) halfBlend = 1;
var nList = state.nodes;
for (var ei = 0; ei < nList.length; ei++) {
var c = nList[ei];
var p = getNode(c.parentId);
if (!p) continue;
var ax = p.x;
var ay = p.y;
var bx = c.x;
var by = c.y;
var sx = bx - ax;
var sy = by - ay;
var slen2 = sx * sx + sy * sy;
if (slen2 < 1e-12) continue;
var elen = Math.sqrt(slen2);
var npx = -sy / elen;
var npy = sx / elen;
for (var pi = 0; pi < nList.length; pi++) {
var P = nList[pi];
if (P.id === p.id || P.id === c.id) continue;
if (P.id === 0) continue;
var qx = P.x - ax;
var qy = P.y - ay;
var t = (qx * sx + qy * sy) / slen2;
if (t < 0) t = 0;
else if (t > 1) t = 1;
var cx = ax + t * sx;
var cy = ay + t * sy;
var dx = P.x - cx;
var dy = P.y - cy;
var dist = Math.hypot(dx, dy);
if (dist >= mar || dist < 1e-6) continue;
var nx = dx / dist;
var ny = dy / dist;
var penetration = mar - dist;
var mag = base * penetration;
if (mag > maxS) mag = maxS;
var rx = nx * mag;
var ry = ny * mag;
var spine = spineChildOfParent(P, p);
var useRadial = true;
if (spine && spine.id !== c.id) {
var crossE = sx * (spine.y - ay) - sy * (spine.x - ax);
var crossP = sx * (P.y - ay) - sy * (P.x - ax);
var eps = 1e-6;
if (Math.abs(crossE) > eps && Math.abs(crossP) > eps && (crossE > 0) !== (crossP > 0)) {
var ts = crossE > 0 ? 1 : -1;
var hx = ts * npx * mag;
var hy = ts * npy * mag;
var om = 1 - halfBlend;
P.x += om * rx + halfBlend * hx;
P.y += om * ry + halfBlend * hy;
useRadial = false;
}
}
if (useRadial) {
P.x += rx;
P.y += ry;
}
}
}
}
function layoutPbdFrame() {
if (document.visibilityState === "hidden") return;
var metrics = getGameMetrics();
ensureRootSeeded(metrics);
refreshSubtreeDescendantCounts();
var iters = layout.subItersPerFrame;
for (var t = 0; t < iters; t++) {
constrainEdgeLengths(metrics);
constrainFanAngles(metrics);
applyNodeRepulsion();
applyLineSegmentRepulsion();
var root = getNode(0);
if (root) {
root.x = metrics.centerX;
root.y = rootPinY(metrics);
}
}
updateScrollBounds();
}
function resizeCanvas() {
var c = els.canvas;
var dpr = window.devicePixelRatio || 1;
var w = c.clientWidth || 800;
var h = c.clientHeight || 600;
c.width = Math.max(1, Math.floor(w * dpr));
c.height = Math.max(1, Math.floor(h * dpr));
ctx = c.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function canvasCssSize() {
return {
w: els.canvas.clientWidth || 800,
h: els.canvas.clientHeight || 600,
};
}
/** World position at top-left of canvas (CSS pixels). */
function worldFromClient(clientX, clientY) {
var r = els.canvas.getBoundingClientRect();
var cs = canvasCssSize();
var lx = clientX - r.left;
var ly = clientY - r.top;
return {
x: camera.x + (lx - cs.w / 2) / camera.zoom,
y: camera.y + (ly - cs.h / 2) / camera.zoom,
};
}
function ensureCameraForMetrics(m) {
if (camera.initialized) return;
camera.x = m.centerX;
camera.y = rootPinY(m);
camera.zoom = 1;
camera.initialized = true;
}
function nodeRadius(n) {
return n.type === "major" ? NODE_R_MAJOR : NODE_R_MINOR;
}
function pickNodeAt(wx, wy) {
var best = null;
var bestD = 1e9;
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
var r = nodeRadius(n);
var d = Math.hypot(n.x - wx, n.y - wy);
if (d <= r + 2 / camera.zoom && d < bestD) {
best = n;
bestD = d;
}
}
return best;
}
function nodeAffordable(n) {
if (!n.owned) return state.skillPoints >= n.unlockCost;
var up = nextLevelCost(n);
return up != null && state.skillPoints >= up;
}
function drawAffordRing(n, r, now) {
var rot = (now / 2600) * Math.PI * 2;
ctx.save();
ctx.translate(n.x, n.y);
ctx.rotate(rot);
var g = ctx.createConicGradient(0, 0, 0);
g.addColorStop(0, "rgba(255,209,102,0)");
g.addColorStop(0.69, "rgba(255,209,102,0)");
g.addColorStop(0.79, "rgba(255,209,102,0.45)");
g.addColorStop(0.85, "rgba(255,255,230,0.85)");
g.addColorStop(0.89, "rgba(255,209,102,0.4)");
g.addColorStop(1, "rgba(255,209,102,0)");
ctx.beginPath();
ctx.arc(0, 0, r + 10, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
var orbitR = n.type === "major" ? 34 : 29;
var ang = (now / 2200) * Math.PI * 2;
var lx = Math.cos(ang) * orbitR;
var ly = Math.sin(ang) * orbitR;
var spot = ctx.createRadialGradient(n.x + lx - 2, n.y + ly - 2, 0, n.x + lx, n.y + ly, 10);
spot.addColorStop(0, "rgba(255,254,242,1)");
spot.addColorStop(0.45, "rgba(255,209,102,0.95)");
spot.addColorStop(1, "rgba(232,160,16,0.9)");
ctx.beginPath();
ctx.arc(n.x + lx, n.y + ly, 5, 0, Math.PI * 2);
ctx.fillStyle = spot;
ctx.fill();
}
function drawNodeCircle(n, now) {
var r = nodeRadius(n);
var affordable = nodeAffordable(n);
if (affordable) drawAffordRing(n, r, now);
var pulseM = 1;
var pulse = pulseUntil[n.id];
if (pulse && now < pulse) {
var u = (now - (pulse - 450)) / 450;
if (u < 0.4) pulseM = 1 + 0.15 * (u / 0.4);
else pulseM = 1.15 - 0.15 * ((u - 0.4) / 0.6);
} else if (pulse && now >= pulse) {
delete pulseUntil[n.id];
}
var hoverM = hoverNodeId === n.id ? 1.08 : 1;
var m = pulseM * hoverM;
ctx.save();
ctx.translate(n.x, n.y);
ctx.scale(m, m);
if (!n.owned) {
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
if (affordable) {
var g = ctx.createRadialGradient(-r * 0.3, -r * 0.3, 0, 0, 0, r);
if (n.type === "minor") {
g.addColorStop(0, "#1a4a45");
g.addColorStop(1, "#0f2624");
} else {
g.addColorStop(0, "#4d2240");
g.addColorStop(1, "#260f18");
}
ctx.fillStyle = g;
ctx.globalAlpha = 0.95;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = "rgba(255,209,102,0.82)";
ctx.lineWidth = 2 / m;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.globalAlpha = 0.48;
var g2 = ctx.createRadialGradient(-r * 0.3, -r * 0.3, 0, 0, 0, r);
if (n.type === "minor") {
g2.addColorStop(0, "#1a3d3a");
g2.addColorStop(1, "#0f2624");
} else {
g2.addColorStop(0, "#3d1a2e");
g2.addColorStop(1, "#260f18");
}
ctx.fillStyle = g2;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = "rgba(136,146,168,0.55)";
ctx.lineWidth = 2 / m;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
} else {
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
var go = ctx.createRadialGradient(-r * 0.35, -r * 0.35, r * 0.1, 0, 0, r);
if (n.type === "minor") {
go.addColorStop(0, "#1a4a45");
go.addColorStop(1, "#0f2624");
} else {
go.addColorStop(0, "#4d2240");
go.addColorStop(1, "#260f18");
}
ctx.fillStyle = go;
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.lineWidth = 2 / m;
ctx.stroke();
if (affordable) {
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,209,102,0.82)";
ctx.lineWidth = 2 / m;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
}
var lab = nodeLabel(n);
var sub = "";
if (n.owned) {
var cap = n.type === "minor" ? getMaxMinorCap() : getMaxMajorCap();
var upCost = nextLevelCost(n);
if (upCost != null) sub = upCost + " SP";
else sub = n.level + "/" + cap;
} else {
sub = n.unlockCost + " SP";
}
var colLab = "#4ecdc4";
if (n.type === "major") colLab = "#ff6b9d";
if (!n.owned && !affordable) colLab = "#8892a8";
if (affordable) colLab = "#ffd166";
var subCol = affordable ? "#ffd166" : "#8892a8";
ctx.font = "bold 10px Segoe UI, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = colLab;
ctx.fillText(lab, 0, -5);
ctx.font = "600 9px Segoe UI, system-ui, sans-serif";
ctx.fillStyle = subCol;
ctx.fillText(sub, 0, 7);
ctx.restore();
}
function drawWorld(now) {
if (!ctx) return;
var cs = canvasCssSize();
ctx.save();
ctx.translate(cs.w / 2, cs.h / 2);
ctx.scale(camera.zoom, camera.zoom);
ctx.translate(-camera.x, -camera.y);
ctx.strokeStyle = "#2a3548";
ctx.lineWidth = 2 / camera.zoom;
for (var i = 0; i < state.nodes.length; i++) {
var c = state.nodes[i];
var p = getNode(c.parentId);
if (!p) continue;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(c.x, c.y);
ctx.stroke();
}
var drawn = state.nodes.slice().sort(function (a, b) {
return a.y - b.y;
});
for (var j = 0; j < drawn.length; j++) {
drawNodeCircle(drawn[j], now);
}
drawIncomeFloaties(now);
ctx.restore();
}
/** +SP popups in world space (pan/zoom with the tree); same motion as former CSS floatUp. */
function drawIncomeFloaties(now) {
var dur = FLOATY_DURATION_MS;
ctx.font = "800 16px Segoe UI, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (var i = 0; i < floaties.length; i++) {
var f = floaties[i];
var d = f.durationMs != null ? f.durationMs : dur;
var t = (now - f.startMs) / d;
if (t >= 1 || t < 0) continue;
var ease = 1 - Math.pow(1 - t, 3);
var drift = 48 * ease;
var sc = 1 + 0.15 * ease;
ctx.save();
ctx.globalAlpha = 1 - t;
ctx.translate(f.wx, f.wy - drift);
ctx.scale(sc, sc);
ctx.shadowColor = "rgba(255, 209, 102, 0.85)";
ctx.shadowBlur = 12;
ctx.fillStyle = "#ffd166";
ctx.fillText(f.text, 0, 0);
ctx.restore();
}
}
function pruneFloaties(now) {
var out = [];
for (var i = 0; i < floaties.length; i++) {
var f = floaties[i];
var d = f.durationMs != null ? f.durationMs : FLOATY_DURATION_MS;
if (now - f.startMs < d) out.push(f);
}
floaties = out;
}
function drawCanvas(now) {
if (!ctx) return;
var cs = canvasCssSize();
var t = now == null ? performance.now() : now;
ctx.clearRect(0, 0, cs.w, cs.h);
pruneFloaties(t);
drawWorld(t);
}
function fullRender() {
var m = getGameMetrics();
ensureRootSeeded(m);
var root0 = getNode(0);
if (root0) {
root0.x = m.centerX;
root0.y = rootPinY(m);
}
updateScrollBounds();
ensureCameraForMetrics(m);
drawCanvas(performance.now());
updateHud();
}
function updateHud() {
els.spDisplay.textContent = state.skillPoints.toFixed(2);
els.rateDisplay.textContent = totalPassiveIncomePerSec().toFixed(3);
els.multDisplay.textContent = "×" + getGlobalIncomeMult().toFixed(3);
els.capMinor.textContent = String(getMaxMinorCap());
els.capMajor.textContent = String(getMaxMajorCap());
}
function activateNode(node) {
if (!node) return;
if (node.owned) {
tryBuyLevel(node);
return;
}
if (state.skillPoints < node.unlockCost) return;
state.skillPoints -= node.unlockCost;
node.owned = true;
if (!node.childrenSpawned) spawnChildren(node);
fullRender();
}
function tryBuyLevel(node) {
var cost = nextLevelCost(node);
if (cost == null || state.skillPoints < cost) return;
state.skillPoints -= cost;
node.level += 1;
fullRender();
}
function spawnFloaty(wx, wy, text, startMs) {
floaties.push({
wx: wx,
wy: wy,
text: text,
startMs: startMs,
durationMs: FLOATY_DURATION_MS,
});
}
function pulseNodeIncome(id) {
pulseUntil[id] = performance.now() + 450;
}
function applyKeyboardCameraPan(dt) {
var speed = 320;
if (keysDown.ArrowUp || keysDown.w) camera.y -= speed * dt;
if (keysDown.ArrowDown || keysDown.s) camera.y += speed * dt;
if (keysDown.ArrowLeft || keysDown.a) camera.x -= speed * dt;
if (keysDown.ArrowRight || keysDown.d) camera.x += speed * dt;
}
function tick(now) {
var dt = Math.min(0.05, (now - lastFrameMs) / 1000);
lastFrameMs = now;
layoutPbdFrame();
applyKeyboardCameraPan(dt);
for (var i = 0; i < state.nodes.length; i++) {
var n = state.nodes[i];
if (!n.owned || n.type !== "minor" || n.subtype !== "income") continue;
var pulseAmt = incomePulseAmount(n);
if (pulseAmt <= 0) continue;
if (now - n.lastPulseMs >= PULSE_INTERVAL_MS) {
n.lastPulseMs = now;
state.skillPoints += pulseAmt;
pulseNodeIncome(n.id);
spawnFloaty(n.x, n.y - 20, "+" + pulseAmt.toFixed(2), now);
}
}
drawCanvas(now);
updateHud();
requestAnimationFrame(tick);
}
function tooltipContent(n) {
var html =
'<div class="title">' + nodeTitle(n) + "</div>" +
'<div class="row">' + nodeEffectLine(n) + "</div>";
if (!n.owned) {
html += '<div class="row">' + tooltipCostHtml(n.unlockCost) + " to unlock</div>";
} else {
var upCost = nextLevelCost(n);
if (upCost != null) {
html += '<div class="row">Next level: ' + tooltipCostHtml(upCost) + "</div>";
html += '<div class="row">After upgrade: ' + nodeEffectLineAtLevel(n, n.level + 1) + "</div>";
} else {
html += '<div class="row">Max level</div>';
}
}
return html;
}
function placeTooltip(clientX, clientY) {
var pad = 12;
var tw = els.tooltip.offsetWidth || 280;
var th = els.tooltip.offsetHeight || 120;
var x = clientX + pad;
var y = clientY + pad;
if (x + tw > window.innerWidth - 8) x = clientX - tw - pad;
if (y + th > window.innerHeight - 8) y = clientY - th - pad;
x = Math.max(8, Math.min(x, window.innerWidth - tw - 8));
y = Math.max(8, Math.min(y, window.innerHeight - th - 8));
els.tooltip.style.left = x + "px";
els.tooltip.style.top = y + "px";
}
function updateCanvasHover(ev) {
var w = worldFromClient(ev.clientX, ev.clientY);
var node = pickNodeAt(w.x, w.y);
hoverNodeId = node ? node.id : null;
if (node) {
els.tooltipInner.innerHTML = tooltipContent(node);
els.tooltip.classList.add("visible");
placeTooltip(ev.clientX, ev.clientY);
} else if (!panDrag) {
hideTooltip();
}
}
function hideTooltip() {
els.tooltip.classList.remove("visible");
}
function onCanvasMouseDown(ev) {
if (ev.button === 2) {
ev.preventDefault();
panDrag = {
mx: ev.clientX,
my: ev.clientY,
camX: camera.x,
camY: camera.y,
};
}
}
function onCanvasMouseMove(ev) {
if (panDrag) {
camera.x = panDrag.camX - (ev.clientX - panDrag.mx) / camera.zoom;
camera.y = panDrag.camY - (ev.clientY - panDrag.my) / camera.zoom;
}
updateCanvasHover(ev);
}
function onCanvasMouseUp(ev) {
if (ev.button === 2) panDrag = null;
}
function onCanvasMouseLeave() {
panDrag = null;
hoverNodeId = null;
hideTooltip();
}
function onCanvasClick(ev) {
if (ev.button !== 0) return;
var w = worldFromClient(ev.clientX, ev.clientY);
var node = pickNodeAt(w.x, w.y);
if (node) activateNode(node);
}
function onCanvasWheel(ev) {
ev.preventDefault();
var w0 = worldFromClient(ev.clientX, ev.clientY);
var z = camera.zoom * Math.exp(-ev.deltaY * 0.0016);
if (z < 0.2) z = 0.2;
if (z > 4) z = 4;
camera.zoom = z;
var w1 = worldFromClient(ev.clientX, ev.clientY);
camera.x += w0.x - w1.x;
camera.y += w0.y - w1.y;
}
function bindCanvasInput() {
var c = els.canvas;
c.addEventListener("mousedown", onCanvasMouseDown);
c.addEventListener("mousemove", onCanvasMouseMove);
c.addEventListener("mouseup", onCanvasMouseUp);
c.addEventListener("mouseleave", onCanvasMouseLeave);
c.addEventListener("click", onCanvasClick);
c.addEventListener("contextmenu", function (ev) {
ev.preventDefault();
});
c.addEventListener("wheel", onCanvasWheel, { passive: false });
window.addEventListener("mouseup", function (ev) {
if (ev.button === 2) panDrag = null;
});
}
window.addEventListener("resize", function () {
resizeCanvas();
fullRender();
});
window.addEventListener("keydown", function (ev) {
if (ev.key === "p" || ev.key === "P") {
state.skillPoints += 100;
updateHud();
return;
}
keysDown[ev.key] = true;
});
window.addEventListener("keyup", function (ev) {
keysDown[ev.key] = false;
});
resizeCanvas();
bindCanvasInput();
initRoot();
fullRender();
lastFrameMs = performance.now();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
remixes
no remixes yet... email to remix@gameslop.net
