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)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();