Files
AICODE2026/3-lessons/AICODE-06/demo-pk/demo-2-pk-battle.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

584 lines
17 KiB
HTML
Raw Permalink 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>涂鸦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>