- 新增 AICODE06-08~11 完整逐字稿教案(每课600+行) - 涂鸦PK主题:画图工具→基础对战→动画音效→班级锦标赛 - 核心工程思维:需求驱动→测试验证→增量迭代→数据驱动 - 更新 AICODE-06 课程大纲,追加第8-11课内容 - 新增 demo-pk/ 目录(画图工具/对战/动画三个demo) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
423 lines
14 KiB
HTML
423 lines
14 KiB
HTML
<!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 已导出!\n(128×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>
|