Files
AICODE2026/3-lessons/AICODE-06/demo-pk/demo-1-draw-tool.html
Rocky 25ec5f0c9c feat: 新增涂鸦PK四课教案(第8-11课)及大纲更新
- 新增 AICODE06-08~11 完整逐字稿教案(每课600+行)
- 涂鸦PK主题:画图工具→基础对战→动画音效→班级锦标赛
- 核心工程思维:需求驱动→测试验证→增量迭代→数据驱动
- 更新 AICODE-06 课程大纲,追加第8-11课内容
- 新增 demo-pk/ 目录(画图工具/对战/动画三个demo)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:28:42 +02:00

423 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>涂鸦角色画图工具</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
font-family: Arial, sans-serif;
color: #fff;
padding: 20px;
gap: 16px;
}
h1 { font-size: 22px; color: #ffd700; letter-spacing: 2px; }
.app { display: flex; gap: 24px; align-items: flex-start; }
/* 左侧画板 */
.left { display: flex; flex-direction: column; gap: 10px; }
.frame-tabs { display: flex; gap: 0; }
.tab {
padding: 8px 20px;
background: #2a2a4a;
border: 2px solid #444;
cursor: pointer;
font-size: 13px;
color: #aaa;
border-bottom: none;
}
.tab:first-child { border-radius: 8px 0 0 0; }
.tab:last-child { border-radius: 0 8px 0 0; }
.tab.active { background: #3a3a6a; border-color: #ffd700; color: #ffd700; }
#display-canvas {
display: block;
cursor: crosshair;
border: 2px solid #ffd700;
image-rendering: pixelated;
}
.tools { display: flex; gap: 8px; flex-wrap: wrap; }
.tool-btn {
padding: 7px 14px;
background: #2a2a4a;
border: 2px solid #555;
border-radius: 6px;
cursor: pointer;
color: #fff;
font-size: 13px;
transition: all 0.1s;
}
.tool-btn.active { border-color: #ffd700; background: #3a3a6a; color: #ffd700; }
.tool-btn:hover:not(.active) { border-color: #888; }
.palette { display: flex; flex-wrap: wrap; gap: 4px; max-width: 520px; }
.swatch {
width: 26px; height: 26px;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
transition: transform 0.1s;
}
.swatch:hover { transform: scale(1.2); }
.swatch.active { border-color: #fff; transform: scale(1.2); }
/* 右侧面板 */
.right { display: flex; flex-direction: column; gap: 12px; min-width: 160px; }
.panel {
background: #16213e;
border: 1px solid #0f3460;
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel h3 { font-size: 12px; color: #ffd700; text-transform: uppercase; letter-spacing: 1px; }
#preview-canvas {
image-rendering: pixelated;
align-self: center;
border: 1px solid #333;
background: #0a0a1a;
}
.btn {
padding: 9px 14px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
width: 100%;
transition: opacity 0.1s;
}
.btn:hover { opacity: 0.85; }
.btn-gold { background: #ffd700; color: #1a1a2e; }
.btn-blue { background: #0f3460; color: #fff; border: 1px solid #1a5276; }
.btn-red { background: #c0392b; color: #fff; }
.hint {
font-size: 11px;
color: #556;
line-height: 1.7;
}
.size-tag { font-size: 11px; color: #556; text-align: center; }
</style>
</head>
<body>
<h1>🎨 涂鸦角色画图工具</h1>
<div class="app">
<div class="left">
<div class="frame-tabs">
<div class="tab active" id="tab0" onclick="switchFrame(0)">帧1 · 待机</div>
<div class="tab" id="tab1" onclick="switchFrame(1)">帧2 · 攻击</div>
</div>
<canvas id="display-canvas"></canvas>
<div class="tools">
<button class="tool-btn active" id="btn-pen" onclick="setTool('pen')">🖊 画笔</button>
<button class="tool-btn" id="btn-eraser" onclick="setTool('eraser')">⬜ 橡皮</button>
<button class="tool-btn" id="btn-fill" onclick="setTool('fill')">🪣 填充</button>
<input type="color" id="custom-color" value="#ffffff"
onchange="setColor(this.value)"
title="自定义颜色"
style="width:36px;height:34px;border:2px solid #555;border-radius:6px;cursor:pointer;background:none;padding:1px;">
</div>
<div class="palette" id="palette"></div>
<div class="size-tag">画布 64×64 像素 · 8倍放大显示</div>
</div>
<div class="right">
<div class="panel">
<h3>动画预览</h3>
<canvas id="preview-canvas" width="128" height="128"></canvas>
<button class="btn btn-blue" onclick="togglePreview()" id="preview-btn">▶ 播放预览</button>
</div>
<div class="panel">
<h3>操作</h3>
<button class="btn btn-blue" onclick="copyFrame1to2()">📋 复制帧1→帧2</button>
<button class="btn btn-red" onclick="clearCurrentFrame()">🗑 清空当前帧</button>
</div>
<div class="panel">
<h3>导出</h3>
<button class="btn btn-gold" onclick="exportSpritesheet()">💾 导出 Spritesheet</button>
<button class="btn btn-blue" onclick="exportCurrentFrame()">💾 导出单帧 PNG</button>
</div>
<div class="panel">
<h3>使用说明</h3>
<div class="hint">
1. 在<b>帧1</b>画待机姿势<br>
2. 点「复制帧1→帧2」<br>
3. 切到<b>帧2</b>修改成攻击姿势<br>
4. 点▶预览动画效果<br>
5. 导出 Spritesheet 用于游戏
</div>
</div>
</div>
</div>
<script>
// ============================================================
// 配置
// ============================================================
const PX = 64; // 画布逻辑尺寸
const ZOOM = 8; // 显示倍率
const W = PX * ZOOM; // 显示尺寸 = 512
// ============================================================
// 数据
// ============================================================
const frameCanvases = [makeOffscreen(), makeOffscreen()];
let currentFrame = 0;
let currentTool = 'pen';
let currentColor = '#ffffff';
let isDrawing = false;
let previewTimer = null;
let previewIdx = 0;
function makeOffscreen() {
const c = document.createElement('canvas');
c.width = PX; c.height = PX;
return c;
}
// ============================================================
// 颜色调色板
// ============================================================
const COLORS = [
'#ffffff','#cccccc','#888888','#444444','#111111',
'#ff4444','#ff8800','#ffcc00','#aaff00','#00ff88',
'#00ffff','#0088ff','#4400ff','#aa00ff','#ff00aa',
'#ffaaaa','#ffddaa','#ffffaa','#aaffcc','#aaddff',
'#8B4513','#d2691e','#f4a460','#228B22','#006400',
'#2e86ab','#e94560','#f5a623','#7ed321','#417505',
];
function buildPalette() {
const el = document.getElementById('palette');
COLORS.forEach(c => {
const s = document.createElement('div');
s.className = 'swatch' + (c === currentColor ? ' active' : '');
s.style.background = c;
s.title = c;
s.onclick = () => {
currentColor = c;
document.getElementById('custom-color').value = c;
document.querySelectorAll('.swatch').forEach(x => x.classList.remove('active'));
s.classList.add('active');
};
el.appendChild(s);
});
}
function setColor(hex) {
currentColor = hex;
document.querySelectorAll('.swatch').forEach(x => x.classList.remove('active'));
}
// ============================================================
// 工具切换
// ============================================================
function setTool(t) {
currentTool = t;
['pen','eraser','fill'].forEach(n => {
document.getElementById('btn-' + n)?.classList.toggle('active', n === t);
});
}
// ============================================================
// 显示画布
// ============================================================
const display = document.getElementById('display-canvas');
display.width = W;
display.height = W;
const dCtx = display.getContext('2d');
dCtx.imageSmoothingEnabled = false;
function render() {
dCtx.clearRect(0, 0, W, W);
// 棋盘格背景(表示透明区域)
for (let y = 0; y < PX; y++) {
for (let x = 0; x < PX; x++) {
dCtx.fillStyle = (x + y) % 2 === 0 ? '#222' : '#1a1a1a';
dCtx.fillRect(x * ZOOM, y * ZOOM, ZOOM, ZOOM);
}
}
// 当前帧像素
dCtx.imageSmoothingEnabled = false;
dCtx.drawImage(frameCanvases[currentFrame], 0, 0, PX, PX, 0, 0, W, W);
// 网格线
dCtx.strokeStyle = 'rgba(255,255,255,0.04)';
dCtx.lineWidth = 0.5;
for (let i = 0; i <= PX; i++) {
dCtx.beginPath(); dCtx.moveTo(i * ZOOM, 0); dCtx.lineTo(i * ZOOM, W); dCtx.stroke();
dCtx.beginPath(); dCtx.moveTo(0, i * ZOOM); dCtx.lineTo(W, i * ZOOM); dCtx.stroke();
}
}
// ============================================================
// 画像素
// ============================================================
function getPos(e) {
const r = display.getBoundingClientRect();
return {
x: Math.max(0, Math.min(PX - 1, Math.floor((e.clientX - r.left) / ZOOM))),
y: Math.max(0, Math.min(PX - 1, Math.floor((e.clientY - r.top) / ZOOM))),
};
}
function putPixel(x, y) {
const fc = frameCanvases[currentFrame].getContext('2d');
if (currentTool === 'eraser') {
fc.clearRect(x, y, 1, 1);
} else {
fc.fillStyle = currentColor;
fc.fillRect(x, y, 1, 1);
}
render();
}
function floodFill(sx, sy) {
const fc = frameCanvases[currentFrame].getContext('2d');
const img = fc.getImageData(0, 0, PX, PX);
const d = img.data;
const ti = (sx + sy * PX) * 4;
const tR = d[ti], tG = d[ti+1], tB = d[ti+2], tA = d[ti+3];
const hex = currentColor.replace('#','');
const fR = parseInt(hex.slice(0,2),16);
const fG = parseInt(hex.slice(2,4),16);
const fB = parseInt(hex.slice(4,6),16);
if (tR===fR && tG===fG && tB===fB && tA===255) return;
const stack = [[sx, sy]];
const seen = new Set();
while (stack.length) {
const [x, y] = stack.pop();
if (x<0||x>=PX||y<0||y>=PX) continue;
const k = x + ',' + y;
if (seen.has(k)) continue;
seen.add(k);
const i = (x + y * PX) * 4;
if (d[i]!==tR||d[i+1]!==tG||d[i+2]!==tB||d[i+3]!==tA) continue;
d[i]=fR; d[i+1]=fG; d[i+2]=fB; d[i+3]=255;
stack.push([x+1,y],[x-1,y],[x,y+1],[x,y-1]);
}
fc.putImageData(img, 0, 0);
render();
}
display.addEventListener('mousedown', e => {
isDrawing = true;
const { x, y } = getPos(e);
currentTool === 'fill' ? floodFill(x, y) : putPixel(x, y);
});
display.addEventListener('mousemove', e => {
if (!isDrawing || currentTool === 'fill') return;
putPixel(getPos(e).x, getPos(e).y);
});
display.addEventListener('mouseup', () => isDrawing = false);
display.addEventListener('mouseleave', () => isDrawing = false);
// ============================================================
// 帧切换
// ============================================================
function switchFrame(idx) {
currentFrame = idx;
document.getElementById('tab0').classList.toggle('active', idx === 0);
document.getElementById('tab1').classList.toggle('active', idx === 1);
render();
}
// ============================================================
// 动画预览
// ============================================================
const previewCanvas = document.getElementById('preview-canvas');
const pCtx = previewCanvas.getContext('2d');
pCtx.imageSmoothingEnabled = false;
function togglePreview() {
const btn = document.getElementById('preview-btn');
if (previewTimer) {
clearInterval(previewTimer);
previewTimer = null;
btn.textContent = '▶ 播放预览';
pCtx.clearRect(0,0,128,128);
return;
}
btn.textContent = '⏹ 停止预览';
previewTimer = setInterval(() => {
pCtx.clearRect(0,0,128,128);
// 棋盘格
for (let y=0;y<4;y++) for (let x=0;x<4;x++) {
pCtx.fillStyle=(x+y)%2===0?'#222':'#1a1a1a';
pCtx.fillRect(x*32,y*32,32,32);
}
pCtx.imageSmoothingEnabled = false;
pCtx.drawImage(frameCanvases[previewIdx], 0, 0, PX, PX, 0, 0, 128, 128);
previewIdx = (previewIdx + 1) % 2;
}, 350);
}
// ============================================================
// 操作
// ============================================================
function copyFrame1to2() {
const src = frameCanvases[0].getContext('2d').getImageData(0,0,PX,PX);
frameCanvases[1].getContext('2d').putImageData(src, 0, 0);
if (currentFrame === 1) render();
alert('✅ 已将帧1复制到帧2\n现在切到帧2修改出攻击姿势吧');
}
function clearCurrentFrame() {
if (!confirm('确定清空当前帧吗?')) return;
frameCanvases[currentFrame].getContext('2d').clearRect(0,0,PX,PX);
render();
}
// ============================================================
// 导出
// ============================================================
function exportSpritesheet() {
const out = document.createElement('canvas');
out.width = PX * 2;
out.height = PX;
const oc = out.getContext('2d');
oc.imageSmoothingEnabled = false;
oc.drawImage(frameCanvases[0], 0, 0);
oc.drawImage(frameCanvases[1], PX, 0);
downloadCanvas(out, 'character-sheet.png');
alert('✅ Spritesheet 已导出!\n128×64 像素包含帧1和帧2');
}
function exportCurrentFrame() {
const name = currentFrame === 0 ? 'character-idle.png' : 'character-attack.png';
downloadCanvas(frameCanvases[currentFrame], name);
}
function downloadCanvas(c, name) {
const a = document.createElement('a');
a.download = name;
a.href = c.toDataURL('image/png');
a.click();
}
// ============================================================
// 初始化
// ============================================================
buildPalette();
render();
</script>
</body>
</html>