CellCraft Visual Mod: Skins & Name Colors (S E N S E)

Change how skins and names look in CellCraft (local only) + S E N S E menu

// ==UserScript==
// @name         CellCraft Visual Mod: Skins & Name Colors (S E N S E)
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Change how skins and names look in CellCraft (local only) + S E N S E menu
// @author       S E N S E
// @license      S E N S E
// @match        *://cellcraft.io/*
// @grant        none
// ==/UserScript==

(function(){
  'use strict';

  /* -------------------------
     Helper: localStorage helpers
     -------------------------*/
  const KEY = 'sense_cell_mod_v1';
  const defaults = {
    mySkinUrl: '',
    myNameColor: '#00ff00',
    othersSkinUrl: '',       // a default skin to replace others with
    othersNameColor: '#ff00ff',
    replacements: {}         // mapping exact names -> {skinUrl, color}
  };
  function loadState(){
    try {
      const raw = localStorage.getItem(KEY);
      if(!raw) return {...defaults};
      return Object.assign({}, defaults, JSON.parse(raw));
    } catch(e){ return {...defaults}; }
  }
  function saveState(s){ localStorage.setItem(KEY, JSON.stringify(s)); }

  let state = loadState();

  /* -------------------------
     UI: Collapsible S E N S E menu (right side)
     -------------------------*/
  function makeMenu(){
    const wrapper = document.createElement('div');
    wrapper.id = 'sense-menu';
    Object.assign(wrapper.style, {
      position: 'fixed',
      top: '12vh',
      right: '12px',
      zIndex: 999999,
      fontFamily: 'Arial, sans-serif',
      userSelect: 'none'
    });

    // collapsed button
    const button = document.createElement('button');
    button.innerText = 'S E N S E';
    Object.assign(button.style, {
      width: '48px',
      height: '48px',
      borderRadius: '8px',
      border: 'none',
      background: 'linear-gradient(90deg,#0f172a,#0ea5a0)',
      color: '#fff',
      cursor: 'pointer',
      boxShadow: '0 4px 12px rgba(0,0,0,0.4)'
    });

    // panel
    const panel = document.createElement('div');
    Object.assign(panel.style, {
      width: '260px',
      padding: '10px',
      background: 'rgba(0,0,0,0.75)',
      color: '#fff',
      borderRadius: '8px',
      marginTop: '8px',
      display: 'none',
      fontSize: '13px',
      boxShadow: '0 6px 18px rgba(0,0,0,0.5)'
    });

    panel.innerHTML = `
      <div style="font-weight:700; margin-bottom:6px">CellCraft Visual Mod</div>
      <div style="font-size:12px; opacity:0.85; margin-bottom:6px">By S E N S E — local visual changes only</div>

      <div style="margin-bottom:8px">
        <div style="font-weight:600">My Skin (image URL)</div>
        <input id="sense-my-skin" placeholder="https://image.png" style="width:100%; margin-top:4px; padding:6px; border-radius:4px; border:none"/>
        <button id="sense-my-skin-set" style="margin-top:6px;padding:6px;border-radius:4px;border:none;cursor:pointer">Apply My Skin</button>
      </div>

      <div style="margin-bottom:8px">
        <div style="font-weight:600">My Name Color</div>
        <input id="sense-my-color" type="color" style="width:44px;height:32px;margin-top:4px;border:0;padding:0"/>
        <button id="sense-my-color-set" style="margin-left:8px;padding:6px;border-radius:4px;border:none;cursor:pointer">Set</button>
      </div>

      <hr style="border:none;border-top:1px solid rgba(255,255,255,0.06); margin:8px 0"/>

      <div style="margin-bottom:8px">
        <div style="font-weight:600">Replace Others' Skin (URL)</div>
        <input id="sense-others-skin" placeholder="https://image.png" style="width:100%; margin-top:4px; padding:6px; border-radius:4px; border:none"/>
        <button id="sense-others-skin-set" style="margin-top:6px;padding:6px;border-radius:4px;border:none;cursor:pointer">Apply to Others</button>
      </div>

      <div style="margin-bottom:8px">
        <div style="font-weight:600">Replace Others' Name Color</div>
        <input id="sense-others-color" type="color" style="width:44px;height:32px;margin-top:4px;border:0;padding:0"/>
        <button id="sense-others-color-set" style="margin-left:8px;padding:6px;border-radius:4px;border:none;cursor:pointer">Set</button>
      </div>

      <hr style="border:none;border-top:1px solid rgba(255,255,255,0.06); margin:8px 0"/>

      <div style="margin-bottom:8px">
        <div style="font-weight:600">Per-player Overrides</div>
        <div style="font-size:12px; opacity:0.9; margin-bottom:6px">Exact name match</div>
        <input id="sense-name-key" placeholder="PlayerName" style="width:48%; padding:6px; border-radius:4px; border:none;"/>
        <input id="sense-name-skin" placeholder="skin URL" style="width:48%; padding:6px; border-radius:4px; border:none; float:right"/>
        <input id="sense-name-color" type="color" style="margin-top:6px;"/>
        <button id="sense-add-repl" style="display:block;margin-top:6px;padding:6px;border-radius:4px;border:none;cursor:pointer">Add / Save Override</button>
        <div id="sense-repl-list" style="margin-top:8px; max-height:90px; overflow:auto; font-size:12px"></div>
      </div>

      <div style="text-align:right; margin-top:8px">
        <button id="sense-reset" style="padding:6px;border-radius:6px;border:none;cursor:pointer">Reset</button>
      </div>
    `;

    wrapper.appendChild(button);
    wrapper.appendChild(panel);
    document.body.appendChild(wrapper);

    // restore inputs
    const inMySkin = panel.querySelector('#sense-my-skin');
    const inMyColor = panel.querySelector('#sense-my-color');
    const inOthersSkin = panel.querySelector('#sense-others-skin');
    const inOthersColor = panel.querySelector('#sense-others-color');

    inMySkin.value = state.mySkinUrl || '';
    inMyColor.value = state.myNameColor || '#00ff00';
    inOthersSkin.value = state.othersSkinUrl || '';
    inOthersColor.value = state.othersNameColor || '#ff00ff';

    // Toggle panel
    button.addEventListener('click', () => {
      panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
    });

    // Buttons
    panel.querySelector('#sense-my-skin-set').addEventListener('click', () => {
      state.mySkinUrl = inMySkin.value.trim();
      saveState(state);
      flash('Applied: my skin');
    });
    panel.querySelector('#sense-my-color-set').addEventListener('click', () => {
      state.myNameColor = inMyColor.value;
      saveState(state);
      flash('Applied: my name color');
    });
    panel.querySelector('#sense-others-skin-set').addEventListener('click', () => {
      state.othersSkinUrl = inOthersSkin.value.trim();
      saveState(state);
      flash('Applied: others skin');
    });
    panel.querySelector('#sense-others-color-set').addEventListener('click', () => {
      state.othersNameColor = inOthersColor.value;
      saveState(state);
      flash('Applied: others name color');
    });

    // per-player overrides
    const nameKey = panel.querySelector('#sense-name-key');
    const nameSkin = panel.querySelector('#sense-name-skin');
    const nameColor = panel.querySelector('#sense-name-color');
    const addBtn = panel.querySelector('#sense-add-repl');
    const replList = panel.querySelector('#sense-repl-list');

    function renderReplList(){
      replList.innerHTML = '';
      const keys = Object.keys(state.replacements || {});
      if(keys.length === 0){ replList.innerHTML = '<i style="opacity:0.7">No overrides</i>'; return; }
      keys.forEach(k => {
        const v = state.replacements[k] || {};
        const div = document.createElement('div');
        div.style.padding = '6px';
        div.style.borderBottom = '1px solid rgba(255,255,255,0.03)';
        div.innerHTML = `<b>${escapeHtml(k)}</b> — color: <span style="color:${v.color||'#fff'}">${v.color||'—'}</span> — skin: ${v.skin ? '<a href="'+escapeHtml(v.skin)+'" target="_blank">link</a>' : '—'} <button data-name="${escapeHtml(k)}" style="float:right">Remove</button>`;
        replList.appendChild(div);
      });
      replList.querySelectorAll('button').forEach(btn=>{
        btn.addEventListener('click', (ev)=>{
          const key = ev.target.getAttribute('data-name');
          delete state.replacements[key];
          saveState(state);
          renderReplList();
          flash('Removed override');
        });
      });
    }
    addBtn.addEventListener('click', ()=>{
      const k = nameKey.value.trim();
      if(!k){ flash('Enter exact player name'); return; }
      state.replacements[k] = {
        skin: nameSkin.value.trim() || null,
        color: nameColor.value || null
      };
      saveState(state);
      renderReplList();
      flash('Saved override for: ' + k);
      nameKey.value = ''; nameSkin.value = '';
    });

    panel.querySelector('#sense-reset').addEventListener('click', ()=>{
      if(confirm('Reset all S E N S E visual settings?')){
        state = {...defaults};
        saveState(state);
        inMySkin.value = ''; inMyColor.value = '#00ff00';
        inOthersSkin.value = ''; inOthersColor.value = '#ff00ff';
        renderReplList();
        flash('Reset done');
      }
    });

    renderReplList();
  }

  function escapeHtml(s){ return String(s).replace(/[&<>"'`]/g, (c)=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;','`':'&#96;' }[c])); }

  function flash(msg){
    const b = document.createElement('div');
    b.innerText = msg;
    Object.assign(b.style, {
      position:'fixed', left:'50%', transform:'translateX(-50%)',
      bottom:'20px', padding:'8px 12px', background:'rgba(0,0,0,0.8)',
      color:'#fff', borderRadius:'8px', zIndex:999999
    });
    document.body.appendChild(b);
    setTimeout(()=>b.remove(), 1800);
  }

  /* -------------------------
     Intercept drawing: drawImage + fillText
     We'll attempt to replace skin images and name colors on-the-fly.
     This is heuristic-based: it changes images/text as they're drawn.
     -------------------------*/

  // small cache for replacement images
  const replacementImageCache = new Map();
  function getReplacementImage(url){
    if(!url) return null;
    if(replacementImageCache.has(url)) return replacementImageCache.get(url);
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.src = url;
    replacementImageCache.set(url, img);
    return img;
  }

  // Hook drawImage to swap skin images with user-provided images.
  const origDrawImage = CanvasRenderingContext2D.prototype.drawImage;
  CanvasRenderingContext2D.prototype.drawImage = function(...args){
    try {
      // args[0] can be an Image, Canvas, Video, or HTMLImageElement
      const source = args[0];
      if(source && source.src && typeof source.src === 'string'){
        const src = source.src;
        // Heuristics: skins/players images often have 'skin' or 'player' in url or are small sprites.
        // We'll apply replacement for:
        //  - if this image is likely a player-sprite by size (e.g., small) OR user set replacements.
        // Replace logic:
        // 1) if there is exact override for a name — we can't know name here, skip.
        // 2) if source corresponds to your own skin (we can't know either), use state.mySkinUrl for any matching sprite pattern.
        // 3) if state.othersSkinUrl exists, and src looks like a skin sprite, swap to that.
        const low = src.toLowerCase();
        const looksLikeSkin = /skin|player|cell|blob|sprite|avatar|face|body/.test(low) || (/\.png$|\.jpg$|\.jpeg$/.test(low) && (source.naturalWidth <= 256 && source.naturalHeight <= 256));
        if(looksLikeSkin){
          // Prefer user-specific URLs if present
          const replUrl = state.othersSkinUrl || state.mySkinUrl;
          if(replUrl){
            const replImg = getReplacementImage(replUrl);
            if(replImg && replImg.complete){
              // draw replacement image instead of original but keep drawing args (position + size)
              args[0] = replImg;
            } else {
              // Not yet loaded: attempt to draw and let browser fetch; still safe to call original
              replImg && replImg.addEventListener('load', ()=>{ /* will be used next draws */ });
            }
          }
        }
      }
    } catch(e){ /* fail silently */ }
    return origDrawImage.apply(this, args);
  };

  // We'll override fillStyle temporarily when drawing names.
  const origFillText = CanvasRenderingContext2D.prototype.fillText;
  CanvasRenderingContext2D.prototype.fillText = function(text, x, y, maxWidth){
    try {
      if(typeof text === 'string' && text.length > 0 && text.length < 60){
        const t = text.trim();

        // exact override
        if(state.replacements && state.replacements[t]){
          const color = state.replacements[t].color || state.othersNameColor || state.myNameColor;
          if(color){
            const prev = this.fillStyle;
            this.fillStyle = color;
            const res = origFillText.apply(this, arguments);
            this.fillStyle = prev;
            return res;
          }
        }

        // if text equals "You" or your own name heuristic: use myNameColor
        // We attempt to detect your name by reading DOM username if present (fallback).
        const myName = detectMyName();
        if(myName && t === myName){
          const prev = this.fillStyle;
          this.fillStyle = state.myNameColor || prev;
          const res = origFillText.apply(this, arguments);
          this.fillStyle = prev;
          return res;
        }

        // Otherwise, if othersNameColor set, color other players
        if(state.othersNameColor){
          // Avoid coloring UI texts by simple heuristics: names are usually short (<24) and not contain colons or spaces-only strings.
          if(t.length <= 24 && !/[:\[\]\(\)]/.test(t)){
            const prev = this.fillStyle;
            this.fillStyle = state.othersNameColor;
            const res = origFillText.apply(this, arguments);
            this.fillStyle = prev;
            return res;
          }
        }
      }
    } catch(e){ /* ignore */ }
    return origFillText.apply(this, arguments);
  };

  // Attempt to discover player's name from page (some games have an input or label)
  function detectMyName(){
    // Many .io games have an input#nick or input[name=nick] — try a few guesses
    const selectors = ['input[name="nick"]','input#nick','input#name','input[name="name"]','input[type="text"]'];
    for(const s of selectors){
      const el = document.querySelector(s);
      if(el && el.value && el.value.trim().length>0) return el.value.trim();
    }
    // fallback: look for elements containing "Nickname" or "Name" — but avoid heavy querying
    return null;
  }

  /* -------------------------
     Small periodic refresh to pick up new state and ensure everything loads
     -------------------------*/
  function refreshState(){
    state = loadState();
  }
  setInterval(refreshState, 2000);

  /* -------------------------
     Create UI after page load
     -------------------------*/
  window.addEventListener('load', () => {
    try { makeMenu(); } catch(e){ console.error('SENSE UI failed', e); }
  });

  // quick apply if script injected after load
  setTimeout(()=>{ try { makeMenu(); } catch(e){} }, 1500);

})();