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>
This commit is contained in:
Rocky
2026-04-09 20:28:42 +02:00
parent 045cc80ca1
commit 25ec5f0c9c
10 changed files with 4785 additions and 244 deletions

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>