Created
October 3, 2025 14:39
-
-
Save EncodeTheCode/20a914d56c7588ef01b8aff5c70febfc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width,initial-scale=1" /> | |
<title>CTR-style Power-up Spinner (Canvas Button)</title> | |
<style> | |
html,body{height:100%;margin:0;background:#222;color:#ddd;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial} | |
#gameCanvas{display:block;width:100%;height:100%} | |
.info{position:fixed;left:8px;top:8px;background:rgba(0,0,0,0.5);padding:8px;border-radius:6px;font-size:13px} | |
</style> | |
</head> | |
<body> | |
<canvas id="gameCanvas"></canvas> | |
<div class="info"> | |
<div><strong>CTR-style Power-up Spinner — Canvas Button Demo</strong></div> | |
<div style="opacity:0.85;font-size:12px;margin-top:6px">Click the canvas <em>Break Crate</em> button or call <code>break_special_crate()</code>. Press <kbd>B</kbd> to force-select after 0.5s of spinning. When an item is selected it stays on screen until you press <kbd>E</kbd> to use it. After using the item it blinks rapidly for 2500ms and disappears.</div> | |
</div> | |
<script> | |
(()=>{ | |
const canvas = document.getElementById('gameCanvas'); | |
const ctx = canvas.getContext('2d'); | |
const DPR = Math.max(1, window.devicePixelRatio || 1); | |
// Box size | |
const BOX_W = 165, BOX_H = 137; | |
// Default position (can be changed with setPosition) | |
let posX = 20.0, posY = 20.0; | |
// Button area (in CSS pixels) | |
const button = { x: 20, y: 180, w: 140, h: 40 }; | |
// Items (colors by default) | |
let items = [ | |
{type:'color', value:'#E53935'}, | |
{type:'color', value:'#FFB300'}, | |
{type:'color', value:'#8E24AA'}, | |
{type:'color', value:'#1E88E5'}, | |
{type:'color', value:'#43A047'}, | |
{type:'color', value:'#F06292'}, | |
{type:'color', value:'#FF7043'}, | |
{type:'color', value:'#7E57C2'} | |
]; | |
let imageCache = new Map(); | |
// Spinner states: 'hidden', 'spinning', 'selected', 'using_blink' | |
let state = 'hidden'; | |
let currentIndex = 0; | |
let spinInterval = null; | |
let spinStart = 0; | |
// timings | |
const FAST_SWITCH_MS = 80; | |
const AUTO_SELECT_MS = 5000; | |
const MIN_B_PRESS_MS = 500; | |
const USE_BLINK_MS = 2500; // blink duration when using item (2.5 seconds) | |
const USE_BLINK_INTERVAL_MS = 60; // blink frequency (smaller = faster blinking) | |
// blink helpers for using | |
let usingBlinkStart = 0; | |
let usingBlinkVisible = true; | |
let usingBlinkTimer = null; | |
// Auto select timer handle | |
let autoSelectTimer = null; | |
// Resize canvas | |
function resize(){ | |
canvas.width = Math.floor(window.innerWidth * DPR); | |
canvas.height = Math.floor(window.innerHeight * DPR); | |
canvas.style.width = window.innerWidth + 'px'; | |
canvas.style.height = window.innerHeight + 'px'; | |
ctx.setTransform(DPR,0,0,DPR,0,0); | |
draw(); | |
} | |
window.addEventListener('resize', resize); | |
resize(); | |
// draw loop | |
function draw(){ | |
// clear | |
ctx.clearRect(0,0,canvas.width/DPR, canvas.height/DPR); | |
// draw button | |
drawButton(); | |
// draw spinner box only when state != 'hidden' | |
if(state !== 'hidden'){ | |
// if using_blink, respect usingBlinkVisible | |
if(state === 'using_blink' && !usingBlinkVisible) return; | |
drawBox(); | |
} | |
} | |
function drawButton(){ | |
ctx.save(); | |
// button background | |
ctx.fillStyle = '#111'; | |
roundRect(ctx, button.x, button.y, button.w, button.h, 8, true, false); | |
// border | |
ctx.lineWidth = 2; | |
ctx.strokeStyle = '#fff4'; | |
roundRect(ctx, button.x+1, button.y+1, button.w-2, button.h-2, 6, false, true); | |
ctx.fillStyle = '#fff'; | |
ctx.font = '16px system-ui,Segoe UI,Roboto'; | |
ctx.textAlign = 'center'; | |
ctx.textBaseline = 'middle'; | |
ctx.fillText(state === 'hidden' ? 'Break Crate' : 'Crate Active', button.x + button.w/2, button.y + button.h/2); | |
ctx.restore(); | |
} | |
function drawBox(){ | |
ctx.save(); | |
// semi-transparent black rectangle (rounded as before) | |
ctx.fillStyle = 'rgba(0,0,0,0.67)'; | |
roundRect(ctx, posX, posY, BOX_W, BOX_H, 8, true, false); | |
// border | |
ctx.lineWidth = 2; | |
ctx.strokeStyle = '#FFFFFF55'; | |
roundRect(ctx, posX+1, posY+1, BOX_W-2, BOX_H-2, 6, false, true); | |
// draw item area - SHARP box (no rounded corners) | |
const padding = 8; | |
const iconSize = Math.min(BOX_W - padding*2, BOX_H - padding*2 - 28); | |
const ix = posX + (BOX_W - iconSize)/2; | |
const iy = posY + padding + 6; | |
// sharp item background box | |
ctx.fillStyle = '#222'; | |
ctx.fillRect(Math.round(ix-4), Math.round(iy-4), Math.round(iconSize+8), Math.round(iconSize+8)); | |
ctx.lineWidth = 1; | |
ctx.strokeStyle = '#333'; | |
ctx.strokeRect(Math.round(ix-4)+0.5, Math.round(iy-4)+0.5, Math.round(iconSize+8), Math.round(iconSize+8)); | |
if(items.length>0){ | |
const it = items[currentIndex % items.length]; | |
// draw item stretched to cover the item area but allow it to overlap outside slightly | |
const overlapFactor = 1.15; // 15% larger to allow overlap | |
const drawSize = iconSize * overlapFactor; | |
const dx = ix + (iconSize - drawSize) / 2; | |
const dy = iy + (iconSize - drawSize) / 2; | |
if(it.type === 'color'){ | |
// stretched color rectangle (may overlap outside the sharp box) | |
ctx.fillStyle = it.value; | |
ctx.fillRect(dx, dy, drawSize, drawSize); | |
} else if(it.type === 'image'){ | |
const img = imageCache.get(it.value); | |
if(img && img.complete){ | |
// draw image stretched (might overlap outside box) | |
ctx.drawImage(img, dx, dy, drawSize, drawSize); | |
} else { | |
ctx.fillStyle = '#666'; | |
ctx.fillRect(dx, dy, drawSize, drawSize); | |
} | |
} | |
} | |
// label text | |
ctx.fillStyle = '#fff'; | |
ctx.font = '12px system-ui,Segoe UI,Roboto'; | |
ctx.textAlign = 'center'; | |
ctx.fillText('Power-up', posX + BOX_W/2, posY + BOX_H - 18); | |
// if selected and waiting for use, show hint | |
if(state === 'selected'){ | |
ctx.font = '11px system-ui,Segoe UI,Roboto'; | |
ctx.fillText('Press E to use', posX + BOX_W/2, posY + BOX_H - 6); | |
} | |
ctx.restore(); | |
} | |
function roundRect(ctx, x, y, w, h, r, fill, stroke){ | |
if(typeof r === 'undefined') r = 5; | |
ctx.beginPath(); | |
ctx.moveTo(x + r, y); | |
ctx.arcTo(x + w, y, x + w, y + h, r); | |
ctx.arcTo(x + w, y + h, x, y + h, r); | |
ctx.arcTo(x, y + h, x, y, r); | |
ctx.arcTo(x, y, x + w, y, r); | |
ctx.closePath(); | |
if(fill) ctx.fill(); | |
if(stroke) ctx.stroke(); | |
} | |
// Start spinning — called by button or API | |
function break_special_crate(){ | |
if(state !== 'hidden') return; // only allow when hidden | |
if(!items || items.length === 0) return; | |
// preload images | |
items.forEach(it => { if(it.type === 'image' && it.value && !imageCache.has(it.value)){ | |
const img = new Image(); img.src = it.value; imageCache.set(it.value, img); | |
}}); | |
state = 'spinning'; | |
spinStart = performance.now(); | |
currentIndex = Math.floor(Math.random() * items.length); | |
// start fast cycling | |
spinInterval = setInterval(()=>{ | |
currentIndex = (currentIndex + 1) % items.length; | |
draw(); | |
}, FAST_SWITCH_MS); | |
// set up auto-select | |
if(autoSelectTimer) clearTimeout(autoSelectTimer); | |
autoSelectTimer = setTimeout(()=>{ | |
if(state === 'spinning') selectCurrent(); | |
}, AUTO_SELECT_MS); | |
draw(); | |
return { startedAt: spinStart }; | |
} | |
// select current item and stop cycling - but do NOT blink or hide until E pressed | |
function selectCurrent(){ | |
if(state !== 'spinning') return; | |
// stop cycling | |
if(spinInterval){ clearInterval(spinInterval); spinInterval = null; } | |
if(autoSelectTimer){ clearTimeout(autoSelectTimer); autoSelectTimer = null; } | |
const selected = { index: currentIndex % items.length, item: items[currentIndex % items.length] }; | |
// dispatch selection event | |
window.dispatchEvent(new CustomEvent('specialCrateSelected', { detail: selected })); | |
// set to selected state and keep visible until used | |
state = 'selected'; | |
draw(); | |
} | |
// when player presses E to use the currently selected item | |
function useSelected(){ | |
if(state !== 'selected') return; | |
state = 'using_blink'; | |
usingBlinkStart = performance.now(); | |
usingBlinkVisible = true; | |
if(usingBlinkTimer) clearInterval(usingBlinkTimer); | |
usingBlinkTimer = setInterval(()=>{ | |
const elapsed = performance.now() - usingBlinkStart; | |
usingBlinkVisible = ((Math.floor(elapsed / USE_BLINK_INTERVAL_MS) % 2) === 0); | |
if(elapsed >= USE_BLINK_MS){ | |
clearInterval(usingBlinkTimer); usingBlinkTimer = null; | |
// finish use: hide and reset | |
state = 'hidden'; | |
currentIndex = 0; | |
draw(); | |
} else { | |
draw(); | |
} | |
}, Math.max(6, Math.floor(USE_BLINK_INTERVAL_MS/2))); | |
// ensure final cleanup | |
setTimeout(()=>{ | |
if(usingBlinkTimer){ clearInterval(usingBlinkTimer); usingBlinkTimer = null; } | |
state = 'hidden'; | |
currentIndex = 0; | |
draw(); | |
}, USE_BLINK_MS + 50); | |
} | |
// keyboard input for B (select early) and E (use) | |
window.addEventListener('keydown', (e)=>{ | |
if(e.key === 'b' || e.key === 'B'){ | |
if(state === 'spinning'){ | |
const elapsed = performance.now() - spinStart; | |
if(elapsed >= MIN_B_PRESS_MS) selectCurrent(); | |
} | |
} else if(e.key === 'e' || e.key === 'E'){ | |
if(state === 'selected'){ | |
useSelected(); | |
} | |
} | |
}); | |
// mouse handling for button click | |
canvas.addEventListener('pointerdown', (ev)=>{ | |
const rect = canvas.getBoundingClientRect(); | |
const x = (ev.clientX - rect.left); // CSS pixels | |
const y = (ev.clientY - rect.top); | |
// check button (button coordinates are in CSS pixels) | |
if(pointInRect(x, y, button)){ | |
// simulate break | |
break_special_crate(); | |
return; | |
} | |
}); | |
function pointInRect(x,y,r){ return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; } | |
// API: setPosition(x,y) places the spinner box | |
function setPosition(x,y){ posX = +x; posY = +y; draw(); } | |
function setItems(arr){ if(!Array.isArray(arr)) throw new Error('setItems expects an array'); items = arr.slice(); items.forEach(it=>{ if(it.type==='image' && it.value && !imageCache.has(it.value)){ const img=new Image(); img.src=it.value; imageCache.set(it.value,img);} }); currentIndex = 0; draw(); } | |
function isSpinnerActive(){ return state === 'spinning' || state === 'selected' || state === 'using_blink'; } | |
// expose functions | |
window.break_special_crate = break_special_crate; | |
window.setPosition = setPosition; | |
window.setItems = setItems; | |
window.isSpinnerActive = isSpinnerActive; | |
// initial draw | |
draw(); | |
console.log('CTR-style spinner (canvas) ready. Use break_special_crate(), setPosition(x,y), setItems(array). Press B to select early while spinning, press E to use the selected item. Listen for "specialCrateSelected" on window.'); | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment