Created
April 10, 2026 02:30
-
-
Save kinjouj/a6590e743165bfd3a51895d1f110d661 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="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Puzzle</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Space+Mono&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #07080f; | |
| --surface: #0d1117; | |
| --surface2:#161b27; | |
| --border: #1e2a42; | |
| --accent: #00f5d4; | |
| --accent2: #f72585; | |
| --accent3: #7b2ff7; | |
| --muted: #4a5568; | |
| --text: #e2e8f8; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Space Mono', monospace; overflow: hidden; } | |
| .shell { height: 100vh; display: flex; flex-direction: column; padding: 12px 14px; gap: 10px; } | |
| /* Topbar */ | |
| .topbar { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } | |
| .upload-btn { | |
| position: relative; overflow: hidden; | |
| background: var(--surface); border: 1px solid var(--border); | |
| color: var(--accent); font-family: 'Orbitron', monospace; | |
| font-size: 0.6rem; letter-spacing: 0.2em; | |
| padding: 8px 14px; cursor: pointer; border-radius: 2px; white-space: nowrap; | |
| } | |
| .upload-btn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } | |
| .filename { font-size: 0.6rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; } | |
| .btn-start { | |
| background: linear-gradient(135deg, var(--accent3), var(--accent2)); | |
| border: none; color: #fff; | |
| font-family: 'Orbitron', monospace; font-size: 0.65rem; font-weight: 700; | |
| letter-spacing: 0.2em; padding: 8px 20px; cursor: pointer; border-radius: 2px; white-space: nowrap; | |
| } | |
| .btn-start:disabled { opacity: .3; cursor: not-allowed; } | |
| /* Progress */ | |
| .prog-row { display: flex; align-items: center; gap: 10px; flex-shrink: 0; } | |
| .prog-label { font-family: 'Orbitron', monospace; font-size: 0.55rem; letter-spacing: .3em; color: var(--muted); white-space: nowrap; } | |
| .prog-label span { color: var(--accent); } | |
| .prog-bar-wrap { flex: 1; height: 2px; background: var(--surface2); border-radius: 2px; overflow: hidden; } | |
| .prog-bar { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent3)); width: 0%; transition: width .3s; } | |
| /* Game area: left=tray, right=board */ | |
| .game-area { display: flex; gap: 12px; flex: 1; min-height: 0; align-items: flex-start; justify-content: center; } | |
| /* Labels */ | |
| .panel-label { font-family: 'Orbitron', monospace; font-size: 0.5rem; letter-spacing: .4em; color: var(--muted); margin-bottom: 10px; flex-shrink: 0; } | |
| /* Tray */ | |
| .tray-col { display: flex; flex-direction: column; flex-shrink: 0; margin-bottom: 12px; margin-right: 24px; } | |
| #tray { display: grid; background: var(--surface); border: 1px solid var(--border); border-radius: 2px; padding: 4px; gap: 3px; flex-shrink: 0; align-content: start; } | |
| /* Board */ | |
| .board-col { display: flex; flex-direction: column; flex-shrink: 0; margin-bottom: 12px; } | |
| #board { display: grid; background: var(--surface); border: 1px solid var(--border); border-radius: 2px; padding: 4px; gap: 3px; flex-shrink: 0; } | |
| .slot { border-radius: 2px; border: 1px dashed rgba(255,255,255,0.07); background: var(--surface2); overflow: hidden; position: relative; flex-shrink: 0; } | |
| .slot.over { border-color: var(--accent); background: rgba(0,245,212,.07); } | |
| .slot.filled { border: none; background: transparent; } | |
| .board-empty { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| gap: 8px; color: var(--muted); text-align: center; pointer-events: none; | |
| } | |
| .board-empty .icon { font-size: 1.8rem; opacity: .2; } | |
| .board-empty p { font-size: 0.6rem; line-height: 1.7; } | |
| .piece { border-radius: 2px; background-size: 100% 100%; cursor: grab; display: block; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.6); } | |
| .piece:active { cursor: grabbing; } | |
| .piece.dragging { opacity: .25; } | |
| .piece.locked { cursor: default; pointer-events: none; } | |
| @keyframes ok { 0% { outline: 2px solid var(--accent); } 100% { outline: 2px solid transparent; } } | |
| @keyframes ng { 0%,100%{transform:translateX(0);outline:2px solid var(--accent2)} 30%{transform:translateX(-3px)} 70%{transform:translateX(3px)} } | |
| .piece.ok { animation: ok .45s ease forwards; } | |
| .piece.ng { animation: ng .3s ease forwards; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <div class="topbar"> | |
| <label class="upload-btn"> | |
| <input type="file" id="file-input" accept="image/*"> | |
| 📁 IMAGE UPLOAD | |
| </label> | |
| </div> | |
| <div class="prog-row" id="prog-row" style="display:none"> | |
| <div class="prog-label">SOLVED <span id="prog-text">0 / 16</span></div> | |
| <div class="prog-bar-wrap"><div class="prog-bar" id="prog-bar"></div></div> | |
| </div> | |
| <div class="game-area" id="game-area" style="display:none"> | |
| <div class="tray-col"> | |
| <div class="panel-label">PIECES</div> | |
| <div id="tray"></div> | |
| </div> | |
| <div class="board-col"> | |
| <div class="panel-label">BOARD</div> | |
| <div id="board"> | |
| <div class="board-empty" id="board-empty"> | |
| <span class="icon">🧩</span> | |
| <p>画像をアップロードして<br>START を押してください</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const N = 4; | |
| const S = { img: null, board: [], tray: [], trayOrder: [], slices: [], drag: null, pSize: 0 }; | |
| const $ = id => document.getElementById(id); | |
| const fileInput = $('file-input'); | |
| const boardEl = $('board'), trayEl = $('tray'); | |
| const progText = $('prog-text'), progBar = $('prog-bar'); | |
| fileInput.addEventListener('change', e => { | |
| const f = e.target.files[0]; | |
| if (!f || !f.type.startsWith('image/')) return; | |
| const r = new FileReader(); | |
| r.onload = ev => { | |
| fileInput.value = ''; // reset so same file can be re-selected | |
| S.img = ev.target.result; | |
| document.getElementById('prog-row').style.display = 'flex'; | |
| document.getElementById('game-area').style.display = 'flex'; | |
| sliceImage(S.img, N, slices => { | |
| S.slices = slices; | |
| S.board = Array(N * N).fill(null); | |
| S.tray = shuffle(Array.from({ length: N * N }, (_, i) => i)); | |
| S.trayOrder = [...S.tray]; | |
| buildLayout(); | |
| updateProg(); | |
| }); | |
| }; | |
| r.readAsDataURL(f); | |
| }); | |
| // Compute piece size so board fits in viewport | |
| function buildLayout() { | |
| const GAP = 3, PAD = 4; | |
| const gameArea = document.querySelector('.game-area'); | |
| const availH = gameArea.clientHeight; | |
| const availW = gameArea.clientWidth - 12; | |
| const LABEL_H = 22 + 12; // panel-label + margin-bottom of col | |
| const byH = Math.floor((availH - PAD*2 - GAP*(N-1) - LABEL_H) / N); | |
| const byW = Math.floor((availW - PAD*4 - GAP*(N-1)*2) / (N * 2)); | |
| const pSize = Math.max(4, Math.min(byH, byW)); | |
| S.pSize = pSize; | |
| const boardSide = pSize * N + PAD*2 + GAP*(N-1); | |
| // Board grid | |
| boardEl.style.width = boardSide + 'px'; | |
| boardEl.style.height = boardSide + 'px'; | |
| boardEl.style.gridTemplateColumns = `repeat(${N}, ${pSize}px)`; | |
| trayEl.style.width = boardSide + 'px'; | |
| trayEl.style.height = boardSide + 'px'; | |
| trayEl.style.gridTemplateColumns = `repeat(${N}, ${pSize}px)`; | |
| renderBoard(); | |
| renderTray(); | |
| } | |
| function renderBoard() { | |
| boardEl.innerHTML = ''; | |
| for (let i = 0; i < N * N; i++) { | |
| const slot = document.createElement('div'); | |
| slot.className = 'slot' + (S.board[i] !== null ? ' filled' : ''); | |
| slot.style.width = slot.style.height = S.pSize + 'px'; | |
| if (S.board[i] !== null) slot.appendChild(makePiece(S.board[i], true)); | |
| slot.addEventListener('dragover', e => { if (S.board[i] !== null) return; e.preventDefault(); slot.classList.add('over'); }); | |
| slot.addEventListener('dragleave', () => slot.classList.remove('over')); | |
| slot.addEventListener('drop', e => { | |
| e.preventDefault(); slot.classList.remove('over'); | |
| if (S.board[i] !== null || !S.drag) return; | |
| placePiece(i, slot); | |
| }); | |
| boardEl.appendChild(slot); | |
| } | |
| } | |
| function renderTray() { | |
| trayEl.innerHTML = ''; | |
| const p = S.pSize; | |
| // S.trayOrder is the fixed shuffled order — show ghost for placed pieces | |
| S.trayOrder.forEach(idx => { | |
| if (S.tray.includes(idx)) { | |
| trayEl.appendChild(makePiece(idx, false)); | |
| } else { | |
| const ghost = document.createElement('div'); | |
| ghost.style.cssText = `width:${p}px;height:${p}px;border-radius:2px;background:var(--surface2);opacity:.15`; | |
| trayEl.appendChild(ghost); | |
| } | |
| }); | |
| } | |
| function makePiece(idx, locked) { | |
| const el = document.createElement('div'); | |
| el.className = 'piece' + (locked ? ' locked' : ''); | |
| el.draggable = true; | |
| el.style.width = el.style.height = S.pSize + 'px'; | |
| el.style.backgroundImage = `url(${S.slices[idx]})`; | |
| el.addEventListener('dragstart', e => { | |
| S.drag = { pieceIdx: idx }; | |
| requestAnimationFrame(() => el.classList.add('dragging')); | |
| e.dataTransfer.effectAllowed = 'move'; | |
| }); | |
| el.addEventListener('dragend', () => el.classList.remove('dragging')); | |
| return el; | |
| } | |
| function placePiece(slotIdx, slotEl) { | |
| const { pieceIdx } = S.drag; S.drag = null; | |
| // If this piece was already on the board, clear its old slot | |
| const prevSlot = S.board.indexOf(pieceIdx); | |
| if (prevSlot !== -1) { | |
| S.board[prevSlot] = null; | |
| const oldSlotEl = boardEl.children[prevSlot]; | |
| oldSlotEl.innerHTML = ''; | |
| oldSlotEl.classList.remove('filled'); | |
| } else { | |
| // Remove from tray | |
| const ti = S.tray.indexOf(pieceIdx); | |
| if (ti !== -1) S.tray.splice(ti, 1); | |
| } | |
| // If target slot already has a piece, send it back to tray | |
| if (S.board[slotIdx] !== null) { | |
| S.tray.push(S.board[slotIdx]); | |
| S.board[slotIdx] = null; | |
| slotEl.innerHTML = ''; | |
| slotEl.classList.remove('filled'); | |
| } | |
| S.board[slotIdx] = pieceIdx; | |
| slotEl.classList.add('filled'); | |
| const p = makePiece(pieceIdx, false); | |
| p.classList.add(pieceIdx === slotIdx ? 'ok' : 'ng'); | |
| setTimeout(() => p.classList.remove('ok', 'ng'), 500); | |
| slotEl.appendChild(p); | |
| renderTray(); | |
| updateProg(); | |
| if (S.board.every((v, i) => v === i)) boardEl.style.pointerEvents = 'none'; | |
| } | |
| function updateProg() { | |
| const correct = S.board.filter((v, i) => v === i).length; | |
| progText.textContent = `${correct} / ${N * N}`; | |
| progBar.style.width = `${(correct / (N * N)) * 100}%`; | |
| } | |
| function sliceImage(src, n, cb) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const sw = img.width / n, sh = img.height / n; | |
| const out = []; | |
| for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) { | |
| const cv = document.createElement('canvas'); | |
| cv.width = sw; cv.height = sh; | |
| cv.getContext('2d').drawImage(img, c*sw, r*sh, sw, sh, 0, 0, sw, sh); | |
| out.push(cv.toDataURL()); | |
| } | |
| cb(out); | |
| }; | |
| img.src = src; | |
| } | |
| function shuffle(a) { | |
| for (let i = a.length-1; i > 0; i--) { const j = Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } | |
| return a; | |
| } | |
| document.addEventListener('dragover', e => e.preventDefault()); | |
| // Drop onto tray → return piece to tray | |
| trayEl.addEventListener('dragover', e => e.preventDefault()); | |
| trayEl.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| if (!S.drag) return; | |
| const { pieceIdx } = S.drag; S.drag = null; | |
| // Only handle pieces coming from the board | |
| const prevSlot = S.board.indexOf(pieceIdx); | |
| if (prevSlot === -1) return; // already in tray, ignore | |
| S.board[prevSlot] = null; | |
| const oldSlotEl = boardEl.children[prevSlot]; | |
| oldSlotEl.innerHTML = ''; | |
| oldSlotEl.classList.remove('filled'); | |
| S.tray.push(pieceIdx); | |
| renderTray(); | |
| updateProg(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment