Xmas Town Optimized (fixed)

Stable notification for items near you in Christmas Town (fixed item name and position parsing, and added an arrow that points to the nearest item)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Xmas Town Optimized (fixed)
// @version     2025.2.0
// @description Stable notification for items near you in Christmas Town (fixed item name and position parsing, and added an arrow that points to the nearest item)
// @match       https://www.torn.com/christmas_town.php*
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// @namespace https://greasyfork.org/users/1540915
// ==/UserScript==

'use strict';

/* ---------------- CONFIG ---------------- */
let playerId = GM_getValue('playerId', null);

GM_registerMenuCommand('Set Player ID', () => {
  const input = prompt('Enter your Torn Player ID:', playerId || '');
  if (input !== null) {
    const id = input.trim();
    if (/^\d+$/.test(id)) {
      playerId = id;
      GM_setValue('playerId', id);
      alert(`Player ID set to: ${id}`);
    } else if (id === '') {
      playerId = null;
      GM_setValue('playerId', null);
      alert('Player ID cleared.');
    } else {
      alert('Invalid Player ID. Please enter a numeric ID.');
    }
  }
});

GM_registerMenuCommand('Debug: Show Position Info', () => {
  const info = [];

  // Check player element by ID
  if (playerId) {
    const playerEl = document.getElementById(`ctUser${playerId}`);
    if (playerEl) {
      info.push(`Player element found: ctUser${playerId}`);
      info.push(`Transform: ${playerEl.style.transform}`);
    } else {
      info.push(`Player element NOT found: ctUser${playerId}`);
    }
  } else {
    info.push('No Player ID configured');
  }

  // Check position span
  const posSpan = document.querySelector("span[class^='position___']");
  if (posSpan) {
    info.push(`Position span found: "${posSpan.textContent}"`);
  } else {
    info.push('Position span NOT found');
  }

  // Check items
  const items = document.querySelectorAll(".items-layer .ct-item");
  info.push(`Items found: ${items.length}`);
  items.forEach((el, i) => {
    const img = el.querySelector("img");
    info.push(`  Item ${i}: left=${el.style.left}, top=${el.style.top}, src=${img?.src || 'no-img'}`);
  });

  alert(info.join('\n'));
});

/* ---------------- CSS ---------------- */
GM_addStyle(`
.ct-item.pulse::after {
  content: "";
  position: absolute;
  inset: -12px;
  border-radius: 50%;
  background: radial-gradient(rgba(255,215,0,0.8), rgba(255,215,0,0));
  animation: pulse 1.8s infinite;
  pointer-events: none;
}

@keyframes pulse {
  0% { opacity: 0.3; }
  50% { opacity: 1; }
  100% { opacity: 0.3; }
}

#cmasnoti ul li { padding-bottom: 8px; }

/* Directional arrow indicator */
.ct-arrow-indicator {
  position: absolute;
  width: 30px;
  height: 30px;
  pointer-events: none;
  z-index: 1000;
  transition: transform 0.3s ease-out, opacity 0.3s ease-out;
  /* Center the arrow on the player, then offset by orbit radius */
  /* We'll set the actual transform via JS */
}

.ct-arrow-indicator svg {
  width: 100%;
  height: 100%;
  filter: drop-shadow(0 0 3px rgba(0,0,0,0.5));
}
`);

/* ---------------- ITEM NAME DETECTION ---------------- */
function getItemName(src) {
  if (!src) return "Item";

  // Check for new animated gif paths
  if (src.includes('/christmas_town/gifts/animated/')) {
    return "Gift";
  }
  if (src.includes('/christmas_town/chests/animated/')) {
    return "Chest";
  }
  if (src.includes('/christmas_town/keys/animated/')) {
    return "Key";
  }

  // Fallback: try to extract something meaningful from the path
  if (src.includes('gift')) return "Gift";
  if (src.includes('chest')) return "Chest";
  if (src.includes('key')) return "Key";

  return "Item";
}

/* ---------------- POSITION HELPERS ---------------- */
function parseTransform(transform) {
  // Parse transform: translate(Xpx, Ypx) and return grid coordinates
  if (!transform) return null;

  const match = transform.match(/translate\(\s*(-?\d+(?:\.\d+)?)\s*px\s*,\s*(-?\d+(?:\.\d+)?)\s*px\s*\)/);
  if (!match) return null;

  const pixelX = parseFloat(match[1]);
  const pixelY = parseFloat(match[2]);

  // Convert pixels to grid: x = pixels/30, y = -pixels/30 (y is inverted)
  const gridX = Math.round(pixelX / 30);
  const gridY = Math.round(pixelY / -30);

  return { x: gridX, y: gridY };
}

function getPlayerPos() {
  // Try to get position from player element using configured ID
  if (playerId) {
    const playerEl = document.getElementById(`ctUser${playerId}`);
    if (playerEl) {
      const pos = parseTransform(playerEl.style.transform);
      if (pos) return pos;
    }
  }

  // Fallback: try to find position from the position display span
  const posSpan = document.querySelector("span[class^='position___']");
  if (posSpan) {
    const match = posSpan.textContent.match(/(-?\d+)\s*,\s*(-?\d+)/);
    if (match) {
      return { x: parseInt(match[1]), y: parseInt(match[2]) };
    }
  }

  // Fallback: try to find the last .ct-user in .users-layer
  const usersLayer = document.querySelector('.users-layer');
  if (usersLayer) {
    const users = usersLayer.querySelectorAll('.ct-user');
    if (users.length > 0) {
      const lastUser = users[users.length - 1];
      const pos = parseTransform(lastUser.style.transform);
      if (pos) return pos;
    }
  }

  return null;
}

function getItemPos(el) {
  // Items use left/top style attributes (pixel values)
  // Same coordinate system as player transform:
  //   left = gridX * 30
  //   top = gridY * -30 (already negative for positive Y)
  const left = parseInt(el.style.left) || 0;
  const top = parseInt(el.style.top) || 0;

  const gridX = Math.round(left / 30);
  const gridY = Math.round(top / -30);

  return { x: gridX, y: gridY };
}

function getNpcPos(npcEl) {
  // NPCs use transform like players
  return parseTransform(npcEl.style.transform);
}

function formatPos(pos) {
  if (!pos) return "?";
  return `${pos.x},${pos.y}`;
}

/* ---------------- ARROW INDICATOR ---------------- */
const ORBIT_RADIUS = 30; // pixels from player center

function createArrowElement() {
  const arrow = document.createElement('div');
  arrow.className = 'ct-arrow-indicator';
  arrow.innerHTML = `
    <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="lime" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
      <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
    </svg>
  `;
  arrow.style.display = 'none';
  return arrow;
}

let arrowElement = null;
let currentArrowAngle = 0; // Track current angle for smooth transitions

function getArrowElement() {
  if (arrowElement) return arrowElement;

  // Find or wait for player element to attach arrow to
  const playerEl = playerId ? document.getElementById(`ctUser${playerId}`) : null;
  if (!playerEl) return null;

  arrowElement = createArrowElement();
  playerEl.appendChild(arrowElement);
  return arrowElement;
}

function calculateAngleToTarget(playerPos, targetPos) {
  // Calculate direction vector from player to target
  const dx = targetPos.x - playerPos.x;
  const dy = targetPos.y - playerPos.y;

  // atan2 gives angle in radians, where 0 = positive X axis (right)
  // Note: In grid coords, +Y is up, but atan2 expects standard math coords
  // Our grid: +X = right, +Y = up
  // atan2(dy, dx) where dy = target.y - player.y, dx = target.x - player.x
  const angleRad = Math.atan2(dy, dx);
  const angleDeg = angleRad * (180 / Math.PI);

  return angleDeg;
}

function normalizeAngle(angle) {
  // Normalize angle to -180 to 180 range
  while (angle > 180) angle -= 360;
  while (angle < -180) angle += 360;
  return angle;
}

function shortestAngleDelta(from, to) {
  // Find the shortest rotation direction
  let delta = normalizeAngle(to - from);
  return delta;
}

function updateArrowIndicator(playerPos, items) {
  const arrow = getArrowElement();
  if (!arrow) return;

  if (!playerPos || items.length === 0) {
    arrow.style.display = 'none';
    return;
  }

  // Find nearest item
  let nearestItem = null;
  let nearestDist = Infinity;

  items.forEach(el => {
    const itemPos = getItemPos(el);
    const dx = itemPos.x - playerPos.x;
    const dy = itemPos.y - playerPos.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    if (dist < nearestDist) {
      nearestDist = dist;
      nearestItem = itemPos;
    }
  });

  if (!nearestItem || nearestDist === 0) {
    arrow.style.display = 'none';
    return;
  }

  // Calculate opacity based on distance (fade when within 10 units)
  // 10+ units = opacity 1.0, 1 unit = opacity 0.55, 0 units = hidden
  // Range maps to 0.5-1.0 opacity
  let opacity = 1;
  if (nearestDist <= 10) {
    opacity = 0.5 + (nearestDist / 10) * 0.5; // Linear fade: 10->1.0, 5->0.75, 1->0.55
  }

  // Calculate angle to nearest item
  const targetAngle = calculateAngleToTarget(playerPos, nearestItem);

  // Calculate shortest rotation path
  const delta = shortestAngleDelta(currentArrowAngle, targetAngle);
  currentArrowAngle = currentArrowAngle + delta;

  // Convert angle to radians for positioning
  const angleRad = currentArrowAngle * (Math.PI / 180);

  // Calculate orbital position
  // Arrow orbits around player center
  // Player element has width: 8px, height: 14px with margins
  // We'll offset from the center of the player element
  const orbitX = Math.cos(angleRad) * ORBIT_RADIUS;
  const orbitY = -Math.sin(angleRad) * ORBIT_RADIUS; // Negate because CSS Y is inverted

  // Position arrow: center it (-15px for half of 30px), then apply orbit offset
  const offsetX = orbitX - 11;
  const offsetY = orbitY - 25;

  // The arrow SVG points right by default (0°)
  // We need to rotate it to point toward the target
  // In CSS, positive rotation is clockwise, but our angle system has positive as counter-clockwise
  // So we negate the angle for CSS rotation
  const cssRotation = -currentArrowAngle;

  arrow.style.transform = `translate(${offsetX}px, ${offsetY}px) rotate(${cssRotation}deg)`;
  arrow.style.opacity = opacity;
  arrow.style.display = 'block';
}

/* ---------------- UI ---------------- */
const panel = document.createElement("div");
panel.id = "cmasnoti";
panel.style.display = "none";
panel.innerHTML = `
<div class="title-green top-round">
<i class="ct-christmastown-icon"></i>
<span>Nearby</span>
</div>
<div class="bottom-round cont-gray p10">
<ul></ul>
</div>
<hr class="page-head-delimiter m-top10">
`;
document.querySelector(".content-wrapper")?.prepend(panel);

const list = panel.querySelector("ul");

/* ---------------- SCAN (THROTTLED) ---------------- */
let lastScan = 0;
const SCAN_DELAY = 900;

function scan() {
  const now = Date.now();
  if (now - lastScan < SCAN_DELAY) return;
  lastScan = now;

  list.innerHTML = "";

  const items = document.querySelectorAll(".items-layer .ct-item");
  const npcs = document.querySelectorAll(".npc");

  if (!items.length && !npcs.length) {
    panel.style.display = "none";
    updateArrowIndicator(null, []);
    return;
  }

  panel.style.display = "block";

  const playerPos = getPlayerPos();

  // Update arrow indicator to point at nearest item
  updateArrowIndicator(playerPos, Array.from(items));

  items.forEach(el => {
    el.classList.add("pulse");
    const img = el.querySelector("img");
    const name = getItemName(img?.src);
    const itemPos = getItemPos(el);

    list.insertAdjacentHTML(
      "beforeend",
      `<li>You found <strong>${name}</strong> at <strong>${formatPos(itemPos)}</strong></li>`
    );
  });

  npcs.forEach(npc => {
    const html = npc.innerHTML.toLowerCase();
    const npcPos = getNpcPos(npc);
    const posStr = formatPos(npcPos);

    if (html.includes("santa")) {
      list.insertAdjacentHTML("beforeend",
        `<li>Santa nearby at <strong>${posStr}</strong></li>`);
    }
    if (html.includes("grinch")) {
      list.insertAdjacentHTML("beforeend",
        `<li><strong>Grinch nearby</strong> at <strong>${posStr}</strong></li>`);
    }
  });
}

/* ---------------- OBSERVER ---------------- */
const root = document.getElementById("christmastownroot");
if (root) {
  new MutationObserver(scan).observe(root, {
    childList: true,
    subtree: true
  });

  // Initial scan
  scan();
}

// Also observe the #world element for transform changes (player movement)
// This provides more responsive arrow updates when moving
function observeWorldMovement() {
  const world = document.getElementById('world');
  if (!world) {
    // Retry until world element exists
    setTimeout(observeWorldMovement, 500);
    return;
  }

  let moveTimeout = null;
  const observer = new MutationObserver((mutations) => {
    // Only react to style changes on the #world element itself
    for (const mutation of mutations) {
      if (mutation.target !== world) continue;

      // Cancel any pending update
      if (moveTimeout) clearTimeout(moveTimeout);

      // Parse transition duration from the world element's style
      // Format: "transform Xs linear" or "transform Xms linear"
      let delayMs = 50; // Default fallback
      const transition = world.style.transition || '';
      const match = transition.match(/transform\s+([\d.]+)(s|ms)/);
      if (match) {
        const value = parseFloat(match[1]);
        const unit = match[2];
        delayMs = unit === 's' ? value * 1000 : value;
      }

      // Wait for transition to complete before updating arrow
      moveTimeout = setTimeout(() => {
        const items = document.querySelectorAll(".items-layer .ct-item");
        const playerPos = getPlayerPos();
        updateArrowIndicator(playerPos, Array.from(items));
      }, delayMs);
      break;
    }
  });

  // Only observe attributes on the #world element itself, not its children
  observer.observe(world, {
    attributes: true,
    attributeFilter: ['style'],
    subtree: false  // Important: don't observe children
  });
}

observeWorldMovement();