更新 AI03 课程内容,替换为 AICODE-03 和 AICODE-06 课程体系

- 删除旧的 AI03 课程文件(第4-5课)
- 新增 AICODE-03 课程体系(AI是怎么想的、AI训练师、我是大作家等)
- 新增 AICODE-06 课程体系(从扣子到代码、个人主页、涂鸦PK等)
This commit is contained in:
chengzi
2026-04-14 21:31:13 +08:00
parent 7487e8451a
commit e542f013b9
29 changed files with 13051 additions and 2999 deletions

View File

@@ -0,0 +1,422 @@
<!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>

View File

@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>涂鸦PK · 对战演示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: Arial, sans-serif;
color: #fff;
overflow: hidden;
}
canvas#game { display: block; }
.ui {
display: flex;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
justify-content: center;
}
.action-btn {
padding: 12px 22px;
border: 2px solid;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: bold;
transition: all 0.12s;
min-width: 110px;
}
.action-btn:hover:not(:disabled) { transform: translateY(-2px); }
.action-btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
.btn-attack { background: #e94560; border-color: #ff6b81; color: #fff; }
.btn-heavy { background: #c0392b; border-color: #e74c3c; color: #fff; }
.btn-block { background: #0f3460; border-color: #1a5276; color: #adf; }
.btn-skill { background: #6c3483; border-color: #9b59b6; color: #fff; }
.log-box {
width: 700px;
max-height: 80px;
overflow-y: auto;
background: rgba(0,0,0,0.5);
border: 1px solid #333;
border-radius: 8px;
padding: 8px 12px;
margin-top: 10px;
font-size: 12px;
color: #ccc;
line-height: 1.8;
}
</style>
</head>
<body>
<canvas id="game" width="700" height="380"></canvas>
<div class="ui" id="action-ui">
<button class="action-btn btn-attack" onclick="playerAction('attack')">⚔️ 普通攻击</button>
<button class="action-btn btn-heavy" onclick="playerAction('heavy')">💥 重击</button>
<button class="action-btn btn-block" onclick="playerAction('block')">🛡 格挡</button>
<button class="action-btn btn-skill" onclick="playerAction('skill')">✨ 特技</button>
</div>
<div class="log-box" id="log"></div>
<script>
// ============================================================
// Web Audio 音效系统
// ============================================================
const AC = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type) {
const recipes = {
hit: { freqs: [300, 60], dur: 0.08, wave: 'sawtooth', vol: 0.35 },
heavyHit: { freqs: [160, 25], dur: 0.20, wave: 'sawtooth', vol: 0.60 },
block: { freqs: [600, 400], dur: 0.10, wave: 'square', vol: 0.25 },
skill: { freqs: [200, 900], dur: 0.30, wave: 'triangle', vol: 0.40 },
death: { freqs: [350, 40], dur: 0.55, wave: 'sine', vol: 0.30 },
victory: { notes: [523,659,784,1047], dur: 0.12, wave: 'sine', vol: 0.35 },
};
if (type === 'victory') {
const r = recipes.victory;
r.notes.forEach((freq, i) => {
setTimeout(() => {
const o = AC.createOscillator();
const g = AC.createGain();
o.connect(g); g.connect(AC.destination);
o.type = r.wave;
o.frequency.setValueAtTime(freq, AC.currentTime);
g.gain.setValueAtTime(r.vol, AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + r.dur);
o.start(); o.stop(AC.currentTime + r.dur);
}, i * 140);
});
return;
}
const r = recipes[type];
if (!r) return;
const o = AC.createOscillator();
const g = AC.createGain();
o.connect(g); g.connect(AC.destination);
o.type = r.wave;
o.frequency.setValueAtTime(r.freqs[0], AC.currentTime);
o.frequency.exponentialRampToValueAtTime(r.freqs[1], AC.currentTime + r.dur);
g.gain.setValueAtTime(r.vol, AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + r.dur);
o.start(); o.stop(AC.currentTime + r.dur);
}
// ============================================================
// 角色配置(学生可替换这里的属性)
// ============================================================
const PLAYER_CONFIG = {
name: '章鱼怪',
emoji: '🐙',
color: '#39ff14',
hp: 50, maxHp: 50,
atk: 15, def: 5, spd: 8,
skill: { name: '电击', type: 'burn', desc: '燃烧:每回合额外-5血持续3回合' },
skillUsed: false,
};
const ENEMY_CONFIG = {
name: '机器人',
emoji: '🤖',
color: '#e94560',
hp: 60, maxHp: 60,
atk: 12, def: 8, spd: 6,
skill: { name: '护甲', type: 'shield', desc: '护甲:下回合格挡所有伤害' },
skillUsed: false,
};
// ============================================================
// 游戏状态
// ============================================================
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const P_BASE_X = 160, E_BASE_X = 540, CHAR_Y = 200;
const state = {
phase: 'player-turn', // player-turn | animating | enemy-turn | gameover
player: { ...PLAYER_CONFIG, x: P_BASE_X, y: CHAR_Y, shakeX: 0, flash: 0, alpha: 1, burn: 0, shielded: false },
enemy: { ...ENEMY_CONFIG, x: E_BASE_X, y: CHAR_Y, shakeX: 0, flash: 0, alpha: 1, burn: 0, shielded: false },
winner: null,
screenShake: 0,
log: [],
};
// ============================================================
// 动画队列
// ============================================================
const animations = [];
let animating = false;
function queueAnim(fn) {
animations.push(fn);
}
async function runAnimations() {
if (animating) return;
animating = true;
setButtonsDisabled(true);
while (animations.length > 0) {
const fn = animations.shift();
await fn();
}
animating = false;
if (state.phase !== 'gameover') {
setButtonsDisabled(false);
}
}
// ============================================================
// 渲染
// ============================================================
function lerp(a, b, t) { return a + (b - a) * t; }
function drawChar(c, facingRight) {
const { x, y, shakeX, flash, alpha, burn, shielded } = c;
const size = 72;
const sx = x + shakeX;
ctx.save();
ctx.globalAlpha = alpha;
// 护盾光环
if (shielded) {
ctx.beginPath();
ctx.arc(sx, y, size / 2 + 14, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(100,180,255,${0.5 + 0.3 * Math.sin(Date.now() / 200)})`;
ctx.lineWidth = 4;
ctx.stroke();
}
// 燃烧光圈
if (burn > 0) {
ctx.beginPath();
ctx.arc(sx, y, size / 2 + 10, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,100,0,${0.4 + 0.2 * Math.sin(Date.now() / 150)})`;
ctx.lineWidth = 3;
ctx.stroke();
}
// 主体(圆形)
ctx.beginPath();
ctx.arc(sx, y, size / 2, 0, Math.PI * 2);
if (flash > 0) {
ctx.fillStyle = `rgba(255,255,255,${flash})`;
} else {
ctx.fillStyle = c.color;
}
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
ctx.lineWidth = 2;
ctx.stroke();
// Emoji 角色符号
ctx.font = '36px serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(c.emoji, sx, y + 2);
ctx.restore();
}
function drawHPBar(c, bx, by) {
const W = 180, H = 16;
const ratio = Math.max(0, c.hp / c.maxHp);
// 背景
ctx.fillStyle = '#1a1a2e';
ctx.beginPath();
ctx.roundRect(bx, by, W, H, 4);
ctx.fill();
// 血量
const barColor = ratio > 0.5 ? '#39ff14' : ratio > 0.25 ? '#ffd700' : '#e94560';
ctx.fillStyle = barColor;
ctx.beginPath();
ctx.roundRect(bx, by, W * ratio, H, 4);
ctx.fill();
// 边框
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(bx, by, W, H, 4);
ctx.stroke();
// 文字
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${Math.max(0, c.hp)} / ${c.maxHp}`, bx + W / 2, by + H / 2);
}
function drawNamePlate(c, x, y, align) {
ctx.font = 'bold 16px Arial';
ctx.textAlign = align;
ctx.textBaseline = 'top';
ctx.fillStyle = c.color;
ctx.fillText(c.name, x, y);
ctx.font = '11px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText(`ATK:${c.atk} DEF:${c.def} SPD:${c.spd}`, x, y + 20);
if (c.burn > 0) {
ctx.fillStyle = '#ff8800';
ctx.fillText(`🔥 燃烧 ×${c.burn}`, x, y + 36);
}
if (c.shielded) {
ctx.fillStyle = '#64b4ff';
ctx.fillText('🛡 护甲中', x, y + 36);
}
}
function render() {
const shake = state.screenShake;
ctx.save();
if (shake > 0) ctx.translate((Math.random() - 0.5) * shake * 8, (Math.random() - 0.5) * shake * 4);
// 背景渐变
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
grad.addColorStop(0, '#1a1a2e');
grad.addColorStop(1, '#16213e');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 地面线
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 260); ctx.lineTo(canvas.width, 260);
ctx.stroke();
// VS 文字
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillText('VS', 350, CHAR_Y);
// 角色
drawChar(state.player, true);
drawChar(state.enemy, false);
// 血条 + 名字
drawHPBar(state.player, 30, 20);
drawHPBar(state.enemy, 490, 20);
drawNamePlate(state.player, 30, 42, 'left');
drawNamePlate(state.enemy, 880, 42, 'right');
// 回合提示
if (state.phase === 'player-turn') {
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#ffd700';
ctx.fillText('你的回合 — 选择行动', 350, 295);
} else if (state.phase === 'enemy-turn') {
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#e94560';
ctx.fillText('敌人回合...', 350, 295);
}
// 特技状态
ctx.font = '11px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = state.player.skillUsed ? '#555' : '#9b59b6';
ctx.fillText(`${state.player.skill.name}${state.player.skillUsed ? '已使用' : '可使用'}`, 30, 330);
// 游戏结束
if (state.phase === 'gameover') {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = state.winner === 'player' ? '#ffd700' : '#e94560';
ctx.fillText(state.winner === 'player' ? '🏆 胜利!' : '💀 失败...', 350, 160);
ctx.font = '18px Arial';
ctx.fillStyle = '#aaa';
ctx.fillText('点击页面重新开始', 350, 220);
}
ctx.restore();
requestAnimationFrame(render);
}
// ============================================================
// 战斗函数
// ============================================================
function calcDamage(atk, def, type) {
const base = Math.max(1, atk - def);
if (type === 'heavy') return Math.floor(base * 1.8);
return base;
}
function addLog(msg) {
state.log.unshift(msg);
const el = document.getElementById('log');
el.innerHTML = state.log.slice(0, 8).map(m => `<span>${m}</span>`).join('<br>');
}
function setButtonsDisabled(d) {
document.querySelectorAll('.action-btn').forEach(b => b.disabled = d);
}
// ============================================================
// 动画工厂
// ============================================================
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
async function animAttack(attacker, defender, heavy) {
const isPlayer = attacker === state.player;
const targetX = isPlayer ? E_BASE_X - 80 : P_BASE_X + 80;
const startX = attacker.x;
const steps = 12;
// 冲向对手
for (let i = 0; i <= steps; i++) {
attacker.x = lerp(startX, targetX, i / steps);
await wait(12);
}
// 受击效果
playSound(heavy ? 'heavyHit' : 'hit');
defender.flash = 1.0;
if (heavy) state.screenShake = 1;
for (let i = 0; i < 6; i++) {
defender.shakeX = (i % 2 === 0 ? 1 : -1) * (heavy ? 14 : 8);
await wait(40);
}
defender.shakeX = 0;
state.screenShake = 0;
// 弹回
for (let i = 0; i <= steps; i++) {
attacker.x = lerp(targetX, startX, i / steps);
defender.flash = Math.max(0, 1 - i / steps);
await wait(10);
}
attacker.x = startX;
defender.flash = 0;
}
async function animDeath(char) {
playSound('death');
for (let i = 0; i <= 20; i++) {
char.alpha = 1 - i / 20;
char.y = CHAR_Y + i * 3;
await wait(30);
}
}
async function animSkill(char) {
playSound('skill');
const startX = char.x;
for (let i = 0; i < 8; i++) {
char.shakeX = (i % 2 === 0 ? 1 : -1) * 6;
char.flash = 0.5;
await wait(50);
}
char.shakeX = 0;
char.flash = 0;
char.x = startX;
}
// ============================================================
// 玩家行动
// ============================================================
async function playerAction(type) {
if (state.phase !== 'player-turn' || animating) return;
if (type === 'skill' && state.player.skillUsed) {
addLog('⚠️ 特技已经使用过了');
return;
}
state.phase = 'animating';
const p = state.player, e = state.enemy;
// 玩家蓄力+出招
if (type === 'block') {
playSound('block');
p.shielded = true;
addLog(`🛡 ${p.name} 进入格挡状态!`);
await wait(400);
} else if (type === 'skill') {
p.skillUsed = true;
await animSkill(p);
if (p.skill.type === 'burn') {
e.burn = 3;
addLog(`🔥 ${p.name} 使用【${p.skill.name}】!${e.name} 进入燃烧状态持续3回合`);
}
} else {
const heavy = type === 'heavy';
const dmg = e.shielded ? 0 : calcDamage(p.atk, e.def, type);
await animAttack(p, e, heavy);
if (e.shielded) {
addLog(`🛡 ${e.name} 格挡了攻击!`);
e.shielded = false;
} else {
e.hp -= dmg;
addLog(`${heavy ? '💥' : '⚔️'} ${p.name} 攻击 ${e.name},造成 ${dmg} 点伤害${heavy ? '(重击!)' : ''}`);
}
}
// 检查敌人死亡
if (e.hp <= 0) {
e.hp = 0;
await animDeath(e);
playSound('victory');
state.phase = 'gameover';
state.winner = 'player';
addLog(`🏆 ${p.name} 获胜!`);
return;
}
// 燃烧 tick
if (e.burn > 0) {
e.hp -= 5; e.burn--;
addLog(`🔥 ${e.name} 受到燃烧伤害 -5 血(剩余 ${e.burn} 回合)`);
if (e.hp <= 0) {
e.hp = 0;
await animDeath(e);
state.phase = 'gameover';
state.winner = 'player';
addLog(`🏆 ${p.name} 获胜!`);
return;
}
}
// 玩家格挡清除
if (type !== 'block') p.shielded = false;
await wait(300);
state.phase = 'enemy-turn';
// 敌人AI回合
await wait(600);
await enemyTurn();
}
// ============================================================
// 敌人AI
// ============================================================
async function enemyTurn() {
const p = state.player, e = state.enemy;
let action = 'attack';
// 简单AIHP低于30%用技能25%概率重击,偶尔格挡
if (!e.skillUsed && e.hp < e.maxHp * 0.3) {
action = 'skill';
} else if (Math.random() < 0.25) {
action = 'heavy';
} else if (Math.random() < 0.15) {
action = 'block';
}
if (action === 'block') {
playSound('block');
e.shielded = true;
addLog(`🛡 ${e.name} 进入格挡状态!`);
await wait(400);
} else if (action === 'skill') {
e.skillUsed = true;
await animSkill(e);
if (e.skill.type === 'shield') {
e.shielded = true;
addLog(`🛡 ${e.name} 使用【${e.skill.name}】!下回合格挡所有伤害`);
}
} else {
const heavy = action === 'heavy';
const dmg = p.shielded ? 0 : calcDamage(e.atk, p.def, action);
await animAttack(e, p, heavy);
if (p.shielded) {
addLog(`🛡 ${p.name} 格挡了攻击!`);
p.shielded = false;
} else {
p.hp -= dmg;
addLog(`${heavy ? '💥' : '⚔️'} ${e.name} 攻击 ${p.name},造成 ${dmg} 点伤害${heavy ? '(重击!)' : ''}`);
}
}
// 燃烧敌人tick
if (p.burn > 0) {
p.hp -= 5; p.burn--;
}
// 检查玩家死亡
if (p.hp <= 0) {
p.hp = 0;
await animDeath(p);
state.phase = 'gameover';
state.winner = 'enemy';
addLog(`💀 ${e.name} 获胜!`);
return;
}
await wait(300);
state.phase = 'player-turn';
addLog('— 你的回合 —');
}
// 点击重开
canvas.addEventListener('click', () => {
if (state.phase === 'gameover') location.reload();
});
// ============================================================
// 启动
// ============================================================
render();
addLog('⚔️ 对战开始!你先出手。');
</script>
</body>
</html>

View File

@@ -0,0 +1,474 @@
<!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;
padding: 24px;
font-family: Arial, sans-serif;
color: #fff;
gap: 20px;
}
h1 { color: #ffd700; font-size: 20px; letter-spacing: 2px; }
canvas { border: 1px solid #333; border-radius: 8px; }
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.anim-btn {
padding: 10px 18px;
border: 2px solid #555;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
background: #16213e;
color: #fff;
transition: all 0.1s;
}
.anim-btn:hover { border-color: #ffd700; color: #ffd700; transform: translateY(-2px); }
.import-area {
display: flex;
align-items: center;
gap: 12px;
background: #16213e;
border: 1px solid #333;
border-radius: 8px;
padding: 12px 20px;
}
.import-area label { font-size: 13px; color: #aaa; }
.import-area input { display: none; }
.import-btn {
padding: 8px 16px;
background: #0f3460;
border: 1px solid #1a5276;
border-radius: 6px;
cursor: pointer;
color: #adf;
font-size: 13px;
}
.desc { font-size: 12px; color: #667; text-align: center; max-width: 600px; line-height: 1.7; }
</style>
</head>
<body>
<h1>🎬 角色动画展示</h1>
<div class="import-area">
<label>导入你画的角色:</label>
<button class="import-btn" onclick="document.getElementById('img-input').click()">📂 选择 PNG</button>
<input type="file" id="img-input" accept="image/png" onchange="loadCustomChar(this)">
<label id="import-hint" style="color:#ffd700;font-size:12px;">(不导入则使用默认角色)</label>
</div>
<canvas id="c" width="600" height="300"></canvas>
<div class="controls">
<button class="anim-btn" onclick="playAnim('idle')">🌀 待机</button>
<button class="anim-btn" onclick="playAnim('attack')">⚔️ 普通攻击</button>
<button class="anim-btn" onclick="playAnim('heavy')">💥 重击</button>
<button class="anim-btn" onclick="playAnim('block')">🛡 格挡</button>
<button class="anim-btn" onclick="playAnim('hit')">😵 受击</button>
<button class="anim-btn" onclick="playAnim('skill')">✨ 释放特技</button>
<button class="anim-btn" onclick="playAnim('death')">💀 死亡</button>
<button class="anim-btn" onclick="playAnim('victory')">🏆 胜利</button>
</div>
<div class="desc">
点击按钮查看不同的动画效果。<br>
所有动画都用 <strong>Phaser Tween</strong> 实现,只需要一张图片,不需要多帧素材。<br>
可以导入你在画图工具里画的角色 PNG看看它配上动画是什么效果。
</div>
<script>
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const CX = canvas.width / 2;
const CY = canvas.height / 2 - 20;
// Web Audio
const AC = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type) {
const R = {
hit: [300, 50, 0.08, 'sawtooth', 0.35],
heavy: [160, 25, 0.20, 'sawtooth', 0.55],
block: [600, 400, 0.10, 'square', 0.25],
skill: [200, 900, 0.30, 'triangle', 0.40],
death: [350, 40, 0.55, 'sine', 0.30],
victory: null,
};
if (type === 'victory') {
[523,659,784,1047].forEach((f,i) => setTimeout(() => {
const o=AC.createOscillator(), g=AC.createGain();
o.connect(g); g.connect(AC.destination);
o.type='sine'; o.frequency.value=f;
g.gain.setValueAtTime(0.3, AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+0.12);
o.start(); o.stop(AC.currentTime+0.12);
}, i*140));
return;
}
const r = R[type]; if (!r) return;
const o=AC.createOscillator(), g=AC.createGain();
o.connect(g); g.connect(AC.destination);
o.type=r[3]; o.frequency.setValueAtTime(r[0], AC.currentTime);
o.frequency.exponentialRampToValueAtTime(r[1], AC.currentTime+r[2]);
g.gain.setValueAtTime(r[4], AC.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+r[2]);
o.start(); o.stop(AC.currentTime+r[2]);
}
// 角色状态
let charImg = null;
let charFrameCount = 1; // 总帧数(导入 spritesheet 时自动检测)
let charFrameW = 0; // 每帧宽度(像素)
let currentCharFrame = 0; // 当前显示第几帧
const char = {
x: CX, y: CY,
scale: 1, scaleY: 1,
rotation: 0,
alpha: 1,
shakeX: 0,
tint: null, // 'red' | 'blue' | null
glow: 0,
particles: [],
};
// 默认角色:用 canvas 画一个可爱的涂鸦怪
const defaultChar = (() => {
const c = document.createElement('canvas');
c.width = 80; c.height = 80;
const cx = c.getContext('2d');
// 身体
cx.fillStyle = '#39ff14';
cx.beginPath(); cx.ellipse(40,45,32,28,0,0,Math.PI*2); cx.fill();
// 眼睛
cx.fillStyle = '#fff';
cx.beginPath(); cx.arc(28,35,9,0,Math.PI*2); cx.fill();
cx.beginPath(); cx.arc(52,35,9,0,Math.PI*2); cx.fill();
cx.fillStyle = '#000';
cx.beginPath(); cx.arc(30,36,5,0,Math.PI*2); cx.fill();
cx.beginPath(); cx.arc(54,36,5,0,Math.PI*2); cx.fill();
// 嘴巴
cx.strokeStyle = '#000'; cx.lineWidth = 2;
cx.beginPath(); cx.arc(40,50,10,0.2,Math.PI-0.2); cx.stroke();
// 手臂
cx.strokeStyle = '#39ff14'; cx.lineWidth = 5;
cx.beginPath(); cx.moveTo(10,50); cx.lineTo(30,40); cx.stroke();
cx.beginPath(); cx.moveTo(70,50); cx.lineTo(50,40); cx.stroke();
// 轮廓
cx.strokeStyle = 'rgba(0,0,0,0.3)'; cx.lineWidth = 2;
cx.beginPath(); cx.ellipse(40,45,32,28,0,0,Math.PI*2); cx.stroke();
return c;
})();
function loadCustomChar(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
charImg = img;
// 自动检测是否是 Spritesheet宽度 >= 高度×1.5 就认为是多帧)
charFrameCount = (img.width >= img.height * 1.5)
? Math.round(img.width / img.height)
: 1;
charFrameW = img.width / charFrameCount;
currentCharFrame = 0;
document.getElementById('import-hint').textContent =
`✅ 已加载:${file.name}(检测到 ${charFrameCount} 帧)`;
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// ============================================================
// 动画系统
// ============================================================
function wait(ms) { return new Promise(r => setTimeout(r, ms)); }
function lerp(a, b, t) { return a + (b - a) * t; }
let isPlaying = false;
async function playAnim(type) {
if (isPlaying) return;
isPlaying = true;
resetChar();
await animMap[type]();
if (type !== 'death') resetChar();
isPlaying = false;
}
function resetChar() {
Object.assign(char, {
x: CX, y: CY,
scale: 1, scaleY: 1,
rotation: 0,
alpha: 1,
shakeX: 0,
tint: null,
glow: 0,
});
char.particles = [];
}
const animMap = {
async idle() {
// 呼吸:上下浮动循环
for (let cycle = 0; cycle < 4; cycle++) {
for (let i = 0; i <= 20; i++) {
char.y = CY + Math.sin(i / 20 * Math.PI) * (-8);
char.scaleY = 1 + Math.sin(i / 20 * Math.PI) * 0.04;
await wait(25);
}
}
},
async attack() {
const targetX = CX + 110;
playSound('hit');
currentCharFrame = 0; // 待机帧
// 蓄力后缩
for (let i = 0; i <= 8; i++) {
char.x = lerp(CX, CX - 25, i / 8);
char.scale = lerp(1, 0.85, i / 8);
await wait(18);
}
// 冲出时切换到攻击帧
currentCharFrame = charFrameCount > 1 ? 1 : 0;
for (let i = 0; i <= 10; i++) {
char.x = lerp(CX - 25, targetX, i / 10);
char.scale = lerp(0.85, 1.1, i / 10);
await wait(12);
}
spawnParticles(char.x + 40, char.y, '#fff', 8);
// 弹回时切回待机帧
currentCharFrame = 0;
for (let i = 0; i <= 12; i++) {
char.x = lerp(targetX, CX, i / 12);
char.scale = lerp(1.1, 1, i / 12);
await wait(14);
}
},
async heavy() {
playSound('heavy');
// 大幅蓄力
for (let i = 0; i <= 15; i++) {
char.x = lerp(CX, CX - 55, i / 15);
char.scale = lerp(1, 0.7, i / 15);
char.scaleY = lerp(1, 1.3, i / 15);
await wait(18);
}
// 爆发冲出时切到攻击帧
currentCharFrame = charFrameCount > 1 ? 1 : 0;
const targetX = CX + 140;
for (let i = 0; i <= 8; i++) {
char.x = lerp(CX - 55, targetX, i / 8);
char.scale = lerp(0.7, 1.4, i / 8);
char.scaleY = lerp(1.3, 0.8, i / 8);
await wait(10);
}
spawnParticles(char.x + 50, char.y, '#ff8800', 20);
// 弹回切回待机帧
currentCharFrame = 0;
for (let i = 0; i <= 14; i++) {
char.x = lerp(targetX, CX, i / 14);
char.scale = lerp(1.4, 1, i / 14);
char.scaleY = lerp(0.8, 1, i / 14);
await wait(14);
}
},
async block() {
playSound('block');
char.tint = 'blue';
for (let i = 0; i <= 6; i++) {
char.scale = lerp(1, 1.15, i / 6);
char.glow = lerp(0, 1, i / 6);
await wait(20);
}
await wait(600);
for (let i = 0; i <= 8; i++) {
char.scale = lerp(1.15, 1, i / 8);
char.glow = lerp(1, 0, i / 8);
await wait(20);
}
char.tint = null;
},
async hit() {
playSound('hit');
char.tint = 'red';
for (let i = 0; i < 7; i++) {
char.shakeX = (i % 2 === 0 ? 1 : -1) * 12;
char.scale = i === 0 ? 0.9 : 1;
await wait(45);
}
char.shakeX = 0;
char.tint = null;
},
async skill() {
playSound('skill');
// 旋转蓄能
for (let i = 0; i <= 20; i++) {
char.rotation = lerp(0, 360, i / 20);
char.scale = lerp(1, 1.4, i / 20);
char.glow = i / 20;
await wait(20);
}
char.rotation = 0;
spawnParticles(char.x, char.y, '#ffd700', 30);
await wait(100);
for (let i = 0; i <= 10; i++) {
char.scale = lerp(1.4, 1, i / 10);
char.glow = lerp(1, 0, i / 10);
await wait(20);
}
},
async death() {
playSound('death');
for (let i = 0; i <= 25; i++) {
char.rotation = lerp(0, 360, i / 25);
char.scale = lerp(1, 0, i / 25);
char.alpha = lerp(1, 0, i / 25);
char.y = lerp(CY, CY + 60, i / 25);
await wait(30);
}
spawnParticles(CX, CY + 30, '#e94560', 25);
},
async victory() {
playSound('victory');
// 跳跃弹跳
for (let bounce = 0; bounce < 3; bounce++) {
for (let i = 0; i <= 15; i++) {
char.y = CY - Math.sin(i / 15 * Math.PI) * (50 - bounce * 12);
char.scaleX = 1 - Math.sin(i / 15 * Math.PI) * 0.15;
char.scaleY = 1 + Math.sin(i / 15 * Math.PI) * 0.2;
await wait(25);
}
if (bounce < 2) spawnParticles(char.x, char.y + 40, '#ffd700', 12);
}
char.scaleX = 1; char.scaleY = 1;
},
};
// ============================================================
// 粒子系统
// ============================================================
function spawnParticles(x, y, color, count) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 2 + Math.random() * 5;
char.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 2,
life: 1,
color,
size: 2 + Math.random() * 4,
});
}
}
function updateParticles() {
char.particles = char.particles.filter(p => p.life > 0);
for (const p of char.particles) {
p.x += p.vx;
p.y += p.vy;
p.vy += 0.18;
p.life -= 0.025;
}
}
// ============================================================
// 渲染循环
// ============================================================
function render() {
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
grad.addColorStop(0, '#1a1a2e');
grad.addColorStop(1, '#16213e');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 地面
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, CY + 60); ctx.lineTo(canvas.width, CY + 60);
ctx.stroke();
updateParticles();
// 粒子
for (const p of char.particles) {
ctx.globalAlpha = p.life;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 角色
const img = charImg || defaultChar;
const SIZE = 80;
const sx = char.x + char.shakeX;
const sy = char.y;
ctx.save();
ctx.globalAlpha = char.alpha;
ctx.translate(sx, sy);
ctx.rotate(char.rotation * Math.PI / 180);
ctx.scale(char.scale * (char.scaleX || 1), char.scaleY);
// 发光效果
if (char.glow > 0) {
ctx.shadowColor = char.tint === 'blue' ? '#64b4ff' : '#ffd700';
ctx.shadowBlur = char.glow * 30;
}
// 计算当前帧的源矩形
const fw = charImg ? charFrameW : img.width;
const fh = charImg ? img.height : img.height;
const fx = charImg ? currentCharFrame * charFrameW : 0;
// 颜色叠加
if (char.tint) {
ctx.drawImage(img, fx, 0, fw, fh, -SIZE/2, -SIZE/2, SIZE, SIZE);
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = char.tint === 'red' ? 'rgba(255,80,80,0.55)' : 'rgba(100,150,255,0.5)';
ctx.fillRect(-SIZE/2, -SIZE/2, SIZE, SIZE);
ctx.globalCompositeOperation = 'source-over';
} else {
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, fx, 0, fw, fh, -SIZE/2, -SIZE/2, SIZE, SIZE);
}
ctx.restore();
// 说明文字
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = '#556';
ctx.fillText('← 点击上方按钮播放动画', CX, canvas.height - 14);
requestAnimationFrame(render);
}
render();
</script>
</body>
</html>