source-code/
snakey
Public
codeCodeinfoIssues 0call_splitPull Requestsplay_circleActions
snakey/src/game/systems/DomScanner.ts
typescript302 lines12.8 KB
import Phaser from 'phaser';
import { IDomBody } from './DomManager';

/**
 * DomScanner is responsible for analyzing the HTML Document Object Model (DOM).
 * It runs recursive tree-walking passes to split plain text content into individual 
 * edible characters, detects interactive media elements, identifies visual card layouts, 
 * and maps all coordinates into Phaser Geom bounds.
 */
export class DomScanner {
  // A standard list of HTML tags that represent interactive, media, or structure elements 
  // which will trigger unique custom chomp animations when eaten.
  private static targetSelector = 'img, svg, video, input, textarea, button, a, select, progress, meter, canvas, hr, iframe, audio';

  /**
   * Main scan function executed to refresh the world of edible elements.
   * Runs in two phases:
   * 1. Finds all text nodes and splits them into span characters.
   * 2. Walk the tree to collect those characters, cards, and target elements.
   * 
   * @param scrollX Current viewport horizontal scroll offset (to convert screen relative positions to absolute Phaser coordinates)
   * @param scrollY Current viewport vertical scroll offset
   * @param gameCanvas Active Phaser game canvas element to exclude from scan
   * @param gameContainer Parent wrapper element of the Phaser game to exclude
   */
  public static scan(scrollX: number, scrollY: number, gameCanvas: HTMLCanvasElement | null = null, gameContainer: HTMLElement | null = null): IDomBody[] {
    const domBodies: IDomBody[] = [];

    // 1. First Pass: Find all text nodes recursively (including inside Shadow DOM)
    const textNodes: Text[] = [];
    this.findTextNodes(document.body, textNodes, gameCanvas, gameContainer);
    
    // Replace text nodes with edible character spans
    textNodes.forEach(textNode => {
      this.replaceTextNodeWithSpans(textNode);
    });

    // 2. Second Pass: Traverse the DOM recursively to collect characters and matching elements
    this.collectEdibleElements(document.body, scrollX, scrollY, domBodies, gameCanvas, gameContainer);

    // 3. Add game container walls (acting as physical barriers until escape pill is eaten)
    this.addGameShellWalls(scrollX, scrollY, domBodies, gameContainer);

    return domBodies;
  }

  /**
   * Evaluates if a DOM element should be excluded from scanning and gameplay.
   * Excludes the game canvas, game container, scripts/styles, and already eaten nodes.
   * Also excludes full-screen fixed overlays (backdrops, modals) to prevent the snake 
   * from being boxed in or blocked by non-interactive layout shells.
   * 
   * @param isTextScan Set to true when called during text parsing to prevent parsing already split spans
   */
  private static isExcludedElement(el: HTMLElement, gameCanvas: HTMLCanvasElement | null, gameContainer: HTMLElement | null, isTextScan: boolean = false): boolean {
    if (el === gameCanvas || el === gameContainer) return true;
    if (gameContainer && gameContainer.contains(el)) return true;
    if (el.dataset.eaten === 'true' || el.closest('[data-eaten="true"]')) return true;

    const tagName = el.tagName.toLowerCase();
    if (tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
      return true;
    }

    if (isTextScan) {
      // Prevents infinite loops: do not scan text nodes that are already children of our injected character spans.
      if (el.classList.contains('edible-char') || el.closest('.edible-char')) {
        return true;
      }
    }

    // Dynamic full-screen fixed overlay check (like backdrops, modal overlays)
    // Ensures that full-screen fixed layout shields on general websites don't act as giant physical blocks.
    const style = window.getComputedStyle(el);
    if (style.position === 'fixed') {
      const rect = el.getBoundingClientRect();
      if (rect.width >= window.innerWidth * 0.9 && rect.height >= window.innerHeight * 0.9) {
        return true;
      }
    }

    return false;
  }

  /**
   * Recursively walks the DOM tree to locate all valid text nodes containing renderable characters.
   * Traverses into elements, element children, and Shadow DOM boundaries.
   */
  private static findTextNodes(node: Node, result: Text[], gameCanvas: HTMLCanvasElement | null = null, gameContainer: HTMLElement | null = null) {
    if (node.nodeType === Node.TEXT_NODE) {
      if (node.nodeValue && node.nodeValue.trim()) {
        const parent = node.parentNode as HTMLElement;
        // Verify parent element isn't marked for exclusion before registering its text.
        if (parent && !parent.closest('script, style, noscript, .edible-char, [data-eaten="true"]')) {
          if (gameContainer && gameContainer.contains(parent)) return;
          result.push(node as Text);
        }
      }
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      const el = node as HTMLElement;
      if (this.isExcludedElement(el, gameCanvas, gameContainer, true)) return;
      
      // Ignore hidden or zero-opacity layout nodes to prevent invisible block collisions.
      const style = window.getComputedStyle(el);
      if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return;

      el.childNodes.forEach(child => this.findTextNodes(child, result, gameCanvas, gameContainer));
      if (el.shadowRoot) {
        el.shadowRoot.childNodes.forEach(child => this.findTextNodes(child, result, gameCanvas, gameContainer));
      }
    }
  }

  /**
   * Replaces a single text node with a document fragment containing individual spans 
   * for each character. This allows the snake to eat text letter-by-letter.
   */
  private static replaceTextNodeWithSpans(textNode: Text) {
    const text = textNode.nodeValue || '';
    const parent = textNode.parentNode;
    if (!parent) return;

    const fragment = document.createDocumentFragment();
    let hasValidChar = false;

    for (let i = 0; i < text.length; i++) {
      const char = text[i];
      // Keep whitespace as normal text nodes to preserve original HTML layout flow.
      if (char.trim() === '') {
        fragment.appendChild(document.createTextNode(char));
      } else {
        const span = document.createElement('span');
        span.textContent = char;
        span.className = 'edible-char';
        span.style.transition = 'all 0.3s ease';
        span.style.display = 'inline-block';
        fragment.appendChild(span);
        hasValidChar = true;
      }
    }

    if (hasValidChar) {
      parent.replaceChild(fragment, textNode);
    }
  }

  /**
   * Style-based card detection heuristic to identify card/block layout components dynamically.
   * Checks if an element has non-trivial boundaries (shadows, borders, or distinct backgrounds).
   * This is decoupled from class names to ensure compatibility with any website.
   */
  private static isCardElement(el: HTMLElement, style: CSSStyleDeclaration, rect: DOMRect): boolean {
    if (el === document.body || el === document.documentElement || el.id === 'root') return false;
    
    // Exclude elements matching targetSelector because they are interactive leaf nodes 
    // and have their own distinct animation profiles.
    if (el.matches(this.targetSelector)) return false;
    
    // Size check: must be at least a small block element (like an icon wrapper, badge, or card).
    // Allows micro-elements like status badges to be eaten as cards.
    if (rect.width < 12 || rect.height < 12) return false;
    
    // Exclude large full-viewport layout sections/wrappers to prevent trapping the snake inside grid shells.
    if (rect.width >= window.innerWidth * 0.95 && rect.height >= window.innerHeight * 0.95) return false;

    // 1. Box shadow (standard visual boundary for modern cards)
    const hasShadow = style.boxShadow !== 'none' && style.boxShadow !== '';
    // 2. Visible border
    const hasBorder = style.borderStyle !== 'none' && style.borderWidth !== '0px' && style.borderColor !== 'transparent';
    // 3. Different background color from transparent
    const hasBg = style.backgroundColor !== 'transparent' && style.backgroundColor !== 'rgba(0, 0, 0, 0)';

    return hasShadow || hasBorder || hasBg;
  }

  /**
   * Traverses the DOM recursively to locate and collect characters, cards, and media elements.
   * Converts viewport coordinates into absolute world coordinates and adds items to domBodies list.
   */
  private static collectEdibleElements(node: Node, scrollX: number, scrollY: number, domBodies: IDomBody[], gameCanvas: HTMLCanvasElement | null = null, gameContainer: HTMLElement | null = null) {
    if (node.nodeType === Node.ELEMENT_NODE) {
      const el = node as HTMLElement;
      if (this.isExcludedElement(el, gameCanvas, gameContainer, false)) return;

      const style = window.getComputedStyle(el);
      if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) return;

      const rect = el.getBoundingClientRect();
      if (rect.width > 0 && rect.height > 0) {
        // Check if it is a split character
        if (el.classList.contains('edible-char')) {
          domBodies.push({
            element: el,
            body: new Phaser.Geom.Rectangle(rect.left + scrollX, rect.top + scrollY, rect.width, rect.height),
            id: `char-${domBodies.length}`,
            hasBeenEaten: false,
            type: 'char'
          });
        }
        // Check if it is a card container (exclude from targetSelector matching to prevent duplicate collisions)
        else if (this.isCardElement(el, style, rect)) {
          if (el.dataset.cardEaten !== 'true') {
            this.addCardWalls(el, rect, scrollX, scrollY, domBodies);
          }
        }
        // Check if it is a media or other interactive element
        else if (el.matches(this.targetSelector)) {
          // Add transition if not already set
          if (!el.style.transition) {
            el.style.transition = 'all 0.3s ease';
          }
          domBodies.push({
            element: el,
            body: new Phaser.Geom.Rectangle(rect.left + scrollX, rect.top + scrollY, rect.width, rect.height),
            id: `media-${domBodies.length}`,
            hasBeenEaten: false,
            type: 'media'
          });
        }
      }

      // Recurse into children
      el.childNodes.forEach(child => this.collectEdibleElements(child, scrollX, scrollY, domBodies, gameCanvas, gameContainer));
      // Recurse into shadow DOM
      if (el.shadowRoot) {
        el.shadowRoot.childNodes.forEach(child => this.collectEdibleElements(child, scrollX, scrollY, domBodies, gameCanvas, gameContainer));
      }
    }
  }

  /**
   * Adds four physical wall segments around the bounding rectangle of a card element.
   * This creates a physical obstacle that the snake must eat through.
   */
  private static addCardWalls(card: HTMLElement, rect: DOMRect, scrollX: number, scrollY: number, domBodies: IDomBody[]) {
    const ax = rect.left + scrollX;
    const ay = rect.top + scrollY;
    const w = rect.width;
    const h = rect.height;
    
    // Scale wall thickness dynamically with container size, capping between 4px and 15px.
    // This prevents small badges/icon boxes from having overlapping massive walls.
    const thick = Math.max(4, Math.min(15, w / 4, h / 4));
    
    if (!card.style.transition) {
      card.style.transition = 'all 0.5s ease';
    }
    
    const walls = [
      new Phaser.Geom.Rectangle(ax, ay, w, thick),
      new Phaser.Geom.Rectangle(ax, ay + h - thick, w, thick),
      new Phaser.Geom.Rectangle(ax, ay, thick, h),
      new Phaser.Geom.Rectangle(ax + w - thick, ay, thick, h),
    ];
    
    walls.forEach((wall, wIdx) => {
      domBodies.push({
        element: card,
        body: wall,
        id: `card-${card.id || 'unnamed'}-wall-${wIdx}`,
        hasBeenEaten: false,
        type: 'cardWall'
      });
    });
  }

  /**
   * Adds four physical walls around the game shell/canvas container.
   * These act as the boundary of the normal game phase.
   */
  private static addGameShellWalls(scrollX: number, scrollY: number, domBodies: IDomBody[], gameContainer: HTMLElement | null = null) {
    const container = gameContainer || document.getElementById('game-container-shell');
    if (container) {
      const rect = container.getBoundingClientRect();
      const ax = rect.left + scrollX;
      const ay = rect.top + scrollY;
      const w = rect.width;
      const h = rect.height;
      const thick = 15;
      
      container.style.transition = 'all 0.5s ease';
      
      const walls = [
        new Phaser.Geom.Rectangle(ax, ay, w, thick),
        new Phaser.Geom.Rectangle(ax, ay + h - thick, w, thick),
        new Phaser.Geom.Rectangle(ax, ay, thick, h),
        new Phaser.Geom.Rectangle(ax + w - thick, ay, thick, h),
      ];
      
      walls.forEach((wall, idx) => {
        domBodies.push({
          element: container,
          body: wall,
          id: `wall-${idx}`,
          hasBeenEaten: false,
          type: 'wall'
        });
      });
    }
  }
}

About

Snakey Web Game is the official hub and sandbox playground for the Snakey project. Built with React 19, Phaser 3, and Tailwind CSS, it offers a central playable zone alongside a Sandbox Playground that lets visitors test eating custom HTML elements. It also hosts and serves self-compiled browser extension packages (ZIP) for Chrome and Firefox, as well as a dynamically-generated bookmarklet installer that enables users to drag-and-drop a shortcut to run the game on any external website.

Web GamePhaserReactTypeScriptTailwind CSSViteBookmarklet

Contributors

1