Pixel-perfect image drawing on Gartic Phone — samples at true canvas resolution for maximum detail
// ==UserScript==
// @name Gartic Phone — Image Draw Bot v8
// @namespace http://tampermonkey.net/
// @license MIT
// @version 8.0
// @description Pixel-perfect image drawing on Gartic Phone — samples at true canvas resolution for maximum detail
// @author GarticImageBot
// @match https://garticphone.com/*
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════
const CFG = {
// Resolution scale: 1.0 = exact canvas pixels, 0.5 = half res, 2.0 = 2x supersampled
// Values above 1.0 give more detail by averaging multiple source pixels per canvas pixel
resolutionScale: 1.0,
// How much adjacent pixels can differ in RGB sum before starting a new stroke
// Lower = more accurate color, more strokes. Higher = fewer strokes, slightly blurred colors
colorTolerance: 12,
// Skip pixels where R, G, and B are all above this value (near-white background)
whiteThreshold: 242,
skipWhite: true,
// Strokes drawn per yielded animation frame — higher = faster but may freeze briefly
chunkSize: 500,
proxyUrl: 'https://corsproxy.io/?',
};
let drawing = false;
let cancelFlag = false;
const sleep = ms => new Promise(r => setTimeout(r, ms));
function setStatus(msg, color) {
const el = document.getElementById('gib-status');
if (el) { el.textContent = msg; if (color) el.style.color = color; }
}
function setProgress(pct) {
const bar = document.getElementById('gib-progress-bar');
const lbl = document.getElementById('gib-progress-label');
if (bar) bar.style.width = Math.min(100, Math.round(pct)) + '%';
if (lbl) lbl.textContent = Math.min(100, Math.round(pct)) + '%';
}
// ═══════════════════════════════════════════
// CANVAS — grab the largest one (the drawing canvas)
// ═══════════════════════════════════════════
function getGameCanvas() {
const all = [...document.querySelectorAll('canvas')];
if (!all.length) return null;
return all.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b));
}
// ═══════════════════════════════════════════
// IMAGE LOADER (direct → CORS proxy → GM blob)
// ═══════════════════════════════════════════
function loadImage(url) {
return new Promise((resolve, reject) => {
const attempt = (src, fallback) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = fallback;
img.src = src;
};
attempt(url, () =>
attempt(CFG.proxyUrl + encodeURIComponent(url), () => {
try {
GM_xmlhttpRequest({
method: 'GET', url, responseType: 'blob',
onload: r => {
const bu = URL.createObjectURL(r.response);
const im = new Image();
im.onload = () => { URL.revokeObjectURL(bu); resolve(im); };
im.onerror = () => reject(new Error('All load methods failed'));
im.src = bu;
},
onerror: () => reject(new Error('GM request failed')),
});
} catch(e) { reject(new Error('Load failed: ' + e.message)); }
})
);
});
}
// ═══════════════════════════════════════════
// CORE: PIXEL-PERFECT STROKE BUILDER
//
// Strategy:
// 1. Scale source image to EXACTLY the game canvas dimensions
// (optionally supersampled at 2x then downscaled for better color accuracy)
// 2. Read every single pixel row
// 3. Run-length encode each row into strokes where adjacent pixels
// have similar enough color to merge
// 4. Each stroke is 1px tall — lineWidth = 1 fills every pixel perfectly
// ═══════════════════════════════════════════
function buildStrokes(img, canvasW, canvasH) {
const scale = CFG.resolutionScale;
// For supersampled modes, we render at Nx then read back at 1x
// This effectively averages Nx pixels into each canvas pixel = better color
const supersample = scale > 1.0 ? Math.ceil(scale) : 1;
// Analysis canvas dimensions = exact game canvas size * supersample
const aw = Math.round(canvasW * supersample);
const ah = Math.round(canvasH * supersample);
// Render source image onto analysis canvas
const ac = document.createElement('canvas');
ac.width = aw;
ac.height = ah;
const ax = ac.getContext('2d');
// White background so transparent PNGs don't get weird colors
ax.fillStyle = '#ffffff';
ax.fillRect(0, 0, aw, ah);
ax.imageSmoothingEnabled = true;
ax.imageSmoothingQuality = 'high';
ax.drawImage(img, 0, 0, aw, ah);
// If supersampling, downscale back to canvas size using a second canvas
// This averages groups of pixels giving smoother, more accurate colors
let finalW = aw, finalH = ah;
let finalData;
if (supersample > 1) {
const bc = document.createElement('canvas');
bc.width = canvasW;
bc.height = canvasH;
const bx = bc.getContext('2d');
bx.fillStyle = '#ffffff';
bx.fillRect(0, 0, canvasW, canvasH);
bx.imageSmoothingEnabled = true;
bx.imageSmoothingQuality = 'high';
bx.drawImage(ac, 0, 0, aw, ah, 0, 0, canvasW, canvasH);
finalData = bx.getImageData(0, 0, canvasW, canvasH).data;
finalW = canvasW;
finalH = canvasH;
} else {
finalData = ax.getImageData(0, 0, aw, ah).data;
finalW = aw;
finalH = ah;
}
const px = finalData;
const tol = CFG.colorTolerance;
const wt = CFG.whiteThreshold;
const sw = CFG.skipWhite;
// strokes: { x1, x2, y, r, g, b }
// x1/x2/y are in CANVAS pixel coordinates (integers)
const strokes = [];
for (let y = 0; y < finalH; y++) {
let runX = -1;
let rr = 0, rg = 0, rb = 0; // current run's averaged color
let count = 0; // pixels accumulated in this run
for (let x = 0; x <= finalW; x++) {
let r = 255, g = 255, b = 255;
let isWhite = true;
if (x < finalW) {
const i = (y * finalW + x) * 4;
r = px[i]; g = px[i + 1]; b = px[i + 2];
// alpha-blend with white background just in case
const a = px[i + 3] / 255;
if (a < 1) { r = Math.round(r * a + 255 * (1-a)); g = Math.round(g * a + 255 * (1-a)); b = Math.round(b * a + 255 * (1-a)); }
isWhite = sw && (r >= wt && g >= wt && b >= wt);
}
// Color drift from running average
const drift = (runX >= 0 && !isWhite)
? (Math.abs(r - rr) + Math.abs(g - rg) + Math.abs(b - rb))
: 0;
const endRun = (x === finalW) || isWhite || drift > tol * 3;
if (endRun && runX >= 0) {
// Emit stroke — coordinates map 1:1 to canvas pixels
strokes.push({
x1: runX,
x2: x, // exclusive end — lineTo(x2, y) draws up to x2
y: y + 0.5, // centre of the pixel row
r: Math.round(rr),
g: Math.round(rg),
b: Math.round(rb),
});
runX = -1; count = 0;
}
if (!isWhite && (runX < 0 || endRun)) {
// Start a new run
runX = x; rr = r; rg = g; rb = b; count = 1;
} else if (!isWhite && runX >= 0) {
// Accumulate running average (weighted towards new pixel)
rr = (rr * count + r) / (count + 1);
rg = (rg * count + g) / (count + 1);
rb = (rb * count + b) / (count + 1);
count++;
}
}
}
return strokes;
}
// ═══════════════════════════════════════════
// DRAW — direct 2D context
// lineWidth = 1 → each stroke is exactly 1 canvas pixel tall
// This means: sampleRows = canvasHeight → zero gaps, zero overlap
// ═══════════════════════════════════════════
async function drawOnCanvas(canvas, strokes, useColor) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Cannot get 2D context');
ctx.save();
ctx.lineCap = 'butt'; // butt = no rounded overrun at stroke ends
ctx.lineJoin = 'miter';
ctx.lineWidth = 1; // 1 canvas pixel = perfect fill at full resolution
let lastColor = null;
const chunk = CFG.chunkSize;
ctx.beginPath();
for (let i = 0; i < strokes.length; i++) {
if (cancelFlag) break;
const s = strokes[i];
const col = useColor
? `rgb(${s.r},${s.g},${s.b})`
: 'rgb(17,17,17)';
if (col !== lastColor) {
if (lastColor !== null) {
ctx.strokeStyle = lastColor;
ctx.stroke();
}
ctx.beginPath();
lastColor = col;
}
ctx.moveTo(s.x1, s.y);
ctx.lineTo(s.x2, s.y);
// Yield every `chunk` strokes so the tab stays responsive
if (i % chunk === chunk - 1) {
ctx.strokeStyle = lastColor;
ctx.stroke();
ctx.beginPath();
setProgress((i / strokes.length) * 100);
await sleep(0);
}
}
// Final flush
if (lastColor) {
ctx.strokeStyle = lastColor;
ctx.stroke();
}
ctx.restore();
}
// ═══════════════════════════════════════════
// MAIN
// ═══════════════════════════════════════════
async function runDraw(imageUrl) {
if (drawing) return;
drawing = true; cancelFlag = false;
setProgress(0);
setStatus('Loading image…', '#fbbf24');
let img;
try { img = await loadImage(imageUrl); }
catch (e) { setStatus('❌ ' + e.message, '#f87171'); drawing = false; return; }
const canvas = getGameCanvas();
if (!canvas) {
setStatus('❌ No canvas — join a drawing round first!', '#f87171');
drawing = false; return;
}
console.log('[GarticBot] canvas:', canvas.width, 'x', canvas.height);
setStatus('Sampling pixels…', '#fbbf24');
await sleep(20);
let strokes;
try { strokes = buildStrokes(img, canvas.width, canvas.height); }
catch (e) { setStatus('❌ ' + e.message, '#f87171'); drawing = false; return; }
console.log('[GarticBot] strokes:', strokes.length.toLocaleString());
const useColor = document.getElementById('gib-usecolor')?.checked ?? true;
setStatus(`Drawing ${strokes.length.toLocaleString()} strokes…`, '#4ade80');
try { await drawOnCanvas(canvas, strokes, useColor); }
catch (e) { setStatus('❌ ' + e.message, '#f87171'); drawing = false; return; }
setProgress(100);
setStatus(
cancelFlag ? '⏹ Cancelled' : '✅ Done! Click DONE in Gartic.',
cancelFlag ? '#f87171' : '#4ade80'
);
drawing = false;
}
// ═══════════════════════════════════════════
// PREVIEW
// ═══════════════════════════════════════════
async function renderPreview(url) {
const box = document.getElementById('gib-preview');
const label = document.getElementById('gib-preview-label');
if (!box) return;
label.textContent = 'Loading…';
box.style.backgroundImage = 'none';
try {
const img = await loadImage(url);
const pc = document.createElement('canvas');
const sc = Math.min(180 / img.naturalWidth, 86 / img.naturalHeight, 1);
pc.width = Math.round(img.naturalWidth * sc);
pc.height = Math.round(img.naturalHeight * sc);
pc.getContext('2d').drawImage(img, 0, 0, pc.width, pc.height);
box.style.backgroundImage = `url(${pc.toDataURL()})`;
box.style.backgroundSize = 'contain';
box.style.backgroundRepeat = 'no-repeat';
box.style.backgroundPosition = 'center';
label.textContent = `${img.naturalWidth} × ${img.naturalHeight}`;
} catch (e) {
label.textContent = '⚠ Preview failed';
}
}
// ═══════════════════════════════════════════
// PANEL
// ═══════════════════════════════════════════
function buildPanel() {
if (document.getElementById('gib-panel')) return;
const panel = document.createElement('div');
panel.id = 'gib-panel';
panel.style.cssText = `
position:fixed;top:16px;right:16px;z-index:2147483647;
width:275px;background:#09090f;
border:1px solid #2a2a50;border-radius:14px;
font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;
color:#c4c4e8;box-shadow:0 8px 40px rgba(0,0,0,.85);
overflow:hidden;user-select:none;
`;
panel.innerHTML = `
<div id="gib-header" style="background:#120e28;padding:11px 14px 9px;cursor:move;
border-bottom:1px solid #1e1e40;display:flex;align-items:center;gap:8px;">
<span style="font-size:17px">🖼️</span>
<div>
<div style="font-weight:700;font-size:14px;color:#a78bfa;letter-spacing:.3px">Image Draw Bot</div>
<div style="font-size:10px;color:#3a3a60;margin-top:1px">v8 — pixel-perfect mode</div>
</div>
<button id="gib-min" style="margin-left:auto;background:transparent;border:1px solid #2a2a50;
border-radius:5px;color:#5050a0;font-size:11px;cursor:pointer;padding:2px 7px;line-height:1.4">_</button>
</div>
<div id="gib-body" style="padding:13px 14px;">
<!-- URL row -->
<div style="font-size:10px;color:#3a3a60;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">Image URL</div>
<div style="display:flex;gap:6px;margin-bottom:9px">
<input id="gib-url" type="text" placeholder="https://…/image.png"
style="flex:1;min-width:0;background:#0f0f1e;border:1px solid #2a2a50;border-radius:7px;
color:#c4c4e8;font-size:11px;padding:6px 8px;outline:none;">
<button id="gib-prev-btn" title="Preview"
style="background:#1a1040;border:1px solid #3a2a70;border-radius:7px;
color:#a78bfa;font-size:14px;cursor:pointer;padding:4px 9px;">👁</button>
</div>
<!-- Preview box -->
<div id="gib-preview" style="width:100%;height:88px;background:#0a0a1a;border:1px solid #1a1a38;
border-radius:8px;margin-bottom:11px;display:flex;align-items:center;justify-content:center;">
<span id="gib-preview-label" style="font-size:11px;color:#2a2a50">No preview</span>
</div>
<!-- Mode selector -->
<div style="margin-bottom:11px">
<div style="font-size:10px;color:#3a3a60;text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px">Resolution</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px">
<button class="gib-m" data-mode="normal"
style="padding:6px 0;background:#0f0f1e;border:1px solid #2a2a50;border-radius:7px;
color:#6060a0;font-size:10px;cursor:pointer;line-height:1.4;">
1× Normal<br>
<span style="font-size:8px;color:#3a3a60">Canvas pixels</span>
</button>
<button class="gib-m" data-mode="super2"
style="padding:6px 0;background:#1a1040;border:1px solid #7c3aed;border-radius:7px;
color:#a78bfa;font-size:10px;cursor:pointer;line-height:1.4;">
2× Super<br>
<span style="font-size:8px;color:#7060a0">Avg 4px→1px</span>
</button>
<button class="gib-m" data-mode="super4"
style="padding:6px 0;background:#0f0f1e;border:1px solid #2a2a50;border-radius:7px;
color:#6060a0;font-size:10px;cursor:pointer;line-height:1.4;">
4× Ultra<br>
<span style="font-size:8px;color:#3a3a60">Avg 16px→1px</span>
</button>
</div>
<div id="gib-mode-info" style="font-size:10px;color:#4a4a70;margin-top:5px;text-align:center;min-height:13px">
Samples image at 2× then averages down — better color accuracy
</div>
</div>
<!-- Options row -->
<div style="display:flex;align-items:center;gap:14px;margin-bottom:10px;font-size:12px">
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input type="checkbox" id="gib-usecolor" checked> Colors
</label>
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input type="checkbox" id="gib-skip-white" checked> Skip white bg
</label>
</div>
<!-- Color tolerance -->
<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#4a4a70;margin-bottom:3px">
<span>Color accuracy</span>
<span id="gib-tol-lbl">High</span>
</div>
<input type="range" id="gib-tol" min="1" max="5" value="3" style="width:100%">
<div style="font-size:9px;color:#3a3a60;margin-top:2px">Higher = fewer strokes but less accurate colors</div>
</div>
<!-- Draw speed -->
<div style="margin-bottom:11px">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#4a4a70;margin-bottom:3px">
<span>Draw speed</span><span id="gib-speed-lbl">Fast</span>
</div>
<input type="range" id="gib-speed" min="1" max="5" value="2" style="width:100%">
</div>
<!-- Status + progress -->
<div style="margin-bottom:10px">
<div id="gib-status" style="font-size:12px;color:#94a3b8;min-height:16px;margin-bottom:5px">
Ready — paste a URL and press ▶
</div>
<div style="background:#0f0f1e;border-radius:4px;height:6px;overflow:hidden;border:1px solid #1e1e40">
<div id="gib-progress-bar" style="height:100%;width:0%;
background:linear-gradient(90deg,#6d28d9,#a78bfa);
border-radius:4px;transition:width .15s"></div>
</div>
<div id="gib-progress-label" style="font-size:10px;color:#3a3a60;text-align:right;margin-top:2px">0%</div>
</div>
<button id="gib-start"
style="width:100%;padding:9px;background:#4c1d95;color:#ddd6fe;border:1px solid #7c3aed;
border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;margin-bottom:6px;">
▶ Start Drawing
</button>
<button id="gib-stop" disabled
style="width:100%;padding:7px;background:#1c0a0a;color:#f87171;border:1px solid #7f1d1d;
border-radius:8px;cursor:pointer;font-size:12px;">
⏹ Cancel
</button>
<div style="font-size:10px;color:#2a2a40;text-align:center;margin-top:8px">
Click DONE in Gartic after drawing finishes
</div>
</div>`;
document.body.appendChild(panel);
// ── Resolution modes ──
const MODES = {
normal: {
resolutionScale: 1.0,
colorTolerance: 12,
info: 'Draws 1 pixel per canvas pixel — maximum sharpness',
},
super2: {
resolutionScale: 2.0,
colorTolerance: 10,
info: 'Samples 4 source pixels per canvas pixel — smoother colors',
},
super4: {
resolutionScale: 4.0,
colorTolerance: 8,
info: 'Samples 16 source pixels per canvas pixel — best for photos',
},
};
function selectMode(m) {
const { resolutionScale, colorTolerance, info } = MODES[m];
CFG.resolutionScale = resolutionScale;
CFG.colorTolerance = colorTolerance;
document.getElementById('gib-mode-info').textContent = info;
document.querySelectorAll('.gib-m').forEach(b => {
const on = b.dataset.mode === m;
b.style.background = on ? '#1a1040' : '#0f0f1e';
b.style.borderColor = on ? '#7c3aed' : '#2a2a50';
b.style.color = on ? '#a78bfa' : '#6060a0';
b.querySelector('span').style.color = on ? '#7060a0' : '#3a3a60';
});
}
document.querySelectorAll('.gib-m').forEach(b => b.addEventListener('click', () => selectMode(b.dataset.mode)));
selectMode('super2');
// ── Skip white ──
document.getElementById('gib-skip-white').addEventListener('change', e => {
CFG.skipWhite = e.target.checked;
});
// ── Color tolerance slider ──
const TOL_LABELS = [, 'Max', 'High', 'Medium', 'Low', 'Min'];
const TOL_VALUES = [, 4, 12, 22, 35, 55 ];
document.getElementById('gib-tol').addEventListener('input', e => {
const v = +e.target.value;
document.getElementById('gib-tol-lbl').textContent = TOL_LABELS[v];
CFG.colorTolerance = TOL_VALUES[v];
});
// default to High (v=2)
document.getElementById('gib-tol').value = 2;
document.getElementById('gib-tol-lbl').textContent = 'High';
CFG.colorTolerance = 12;
// ── Draw speed ──
const SL = [, 'Fastest', 'Fast', 'Normal', 'Slow', 'Slowest'];
const SC = [, 1000, 500, 150, 40, 8];
document.getElementById('gib-speed').addEventListener('input', e => {
const v = +e.target.value;
document.getElementById('gib-speed-lbl').textContent = SL[v];
CFG.chunkSize = SC[v];
});
// ── Preview ──
document.getElementById('gib-prev-btn').addEventListener('click', () => {
const u = document.getElementById('gib-url').value.trim();
if (u) renderPreview(u);
else setStatus('Enter a URL first', '#f87171');
});
document.getElementById('gib-url').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('gib-prev-btn').click();
});
// ── Start ──
document.getElementById('gib-start').addEventListener('click', async () => {
const url = document.getElementById('gib-url').value.trim();
if (!url) { setStatus('❌ Paste an image URL first', '#f87171'); return; }
if (drawing) return;
document.getElementById('gib-start').disabled = true;
document.getElementById('gib-stop').disabled = false;
await runDraw(url);
document.getElementById('gib-start').disabled = false;
document.getElementById('gib-stop').disabled = true;
});
// ── Cancel ──
document.getElementById('gib-stop').addEventListener('click', () => {
cancelFlag = true;
document.getElementById('gib-stop').disabled = true;
document.getElementById('gib-start').disabled = false;
});
// ── Minimize ──
let min = false;
document.getElementById('gib-min').addEventListener('click', () => {
min = !min;
document.getElementById('gib-body').style.display = min ? 'none' : 'block';
document.getElementById('gib-min').textContent = min ? '□' : '_';
});
// ── Drag ──
let drag=false, ox,oy,sr,st;
document.getElementById('gib-header').addEventListener('mousedown', e => {
drag=true; ox=e.clientX; oy=e.clientY;
const r = panel.getBoundingClientRect();
sr = window.innerWidth - r.right; st = r.top;
});
document.addEventListener('mousemove', e => {
if (!drag) return;
panel.style.right = Math.max(0, sr-(e.clientX-ox)) + 'px';
panel.style.top = Math.max(0, st+(e.clientY-oy)) + 'px';
panel.style.left = 'auto';
});
document.addEventListener('mouseup', () => { drag=false; });
}
// ═══════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════
const init = () => { console.log('[GarticBot] v8 loaded'); buildPanel(); };
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: setTimeout(init, 600);
})();