preview
LMB (choose skill)
🔥 slow (GPU melter) 📱 RIP mobile users
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