Files
AICODE2026/prototype/单词塔防/index.html
Rocky 1c5e72676b feat: AICODE-06 春季后半 7 课大纲 + 第 12 课教案 + prototype 工程产物
## 主要变更

### 课程设计
- 大纲扩展到 18 课(新增第 12-18 课:单词塔防 3D 大项目)
- 引入 AI 三角色协作工作流(Planner / Reviewer / Tester)作为整学期框架
- 每课详化:核心概念 + 误概念预设 + 教学锚点 + 学生产出 + 老师课前要准备

### 第 12 课教案(完整逐字稿)
- 主题:Skills 入门 - 用 game-studio 做跳一跳
- 90 分钟 4C 结构 + 诊断点 + 分支策略
- 5 个误概念预设 + AI 助教提示词模板 + 教师备课指南

### prototype 工程产物(可玩 demo)
- 跳一跳-3d/index.html:Three.js 3D 跳一跳(蓄力 + Web Audio 音效 + PERFECT 命中)
- 单词塔防/game-3d.html:完整 3D 塔防(三阶段 + 商店 + 卡片 + 战斗循环,15 击杀完美胜利)
- 单词塔防/level-editor-3d.html:3D 关卡设计器(Kenney GLB 模型 + localStorage 保存)
- 单词塔防/level-editor.html:2D 关卡设计器(原型保留)
- 单词塔防/index.html:2D 塔防原型(原型保留)

### 工程加固
- .gitignore 加强:排除 token、Kenney 大素材、调试截图、第三方插件、Playwright 临时
- 从 git tracking 移除 scripts/.dingtalk_token.json(本地保留)
- scripts/sync_to_dingtalk.py:OAuth 流程改为手动 authCode 粘贴(避免本地 server 受限)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:04:54 +02:00

1011 lines
30 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>单词塔防 v0.2 · 穹狼学徒</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", sans-serif;
background: #0d1b2a;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
user-select: none;
}
h1 {
color: #ffd700;
font-size: 22px;
text-shadow: 2px 2px 0 #000;
margin-bottom: 4px;
}
.phase-banner {
background: rgba(255, 215, 0, 0.15);
color: #ffd700;
padding: 8px 20px;
border-radius: 6px;
margin-bottom: 12px;
font-size: 14px;
font-weight: bold;
}
/* HUD - DOM 不进 canvas(phaser-2d-game skill 推荐) */
.hud {
display: flex;
gap: 24px;
margin-bottom: 10px;
font-size: 15px;
font-weight: bold;
}
.hud .hp { color: #ff5577; }
.hud .gold { color: #ffd700; }
.hud .progress { color: #88ddff; }
/* 游戏画布 */
#game {
border: 3px solid #ffd700;
border-radius: 8px;
box-shadow: 0 0 30px rgba(255, 215, 0, 0.3);
}
/* 学习阶段:中文题面 + 输入框 */
.learning-panel {
margin-top: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 16px 24px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 560px;
}
.question {
font-size: 32px;
color: #ffd700;
}
.question .emoji { font-size: 36px; margin-right: 8px; }
#word-input {
padding: 12px 18px;
font-size: 20px;
background: #1a1a2e;
color: #ffd700;
border: 2px solid #ffd700;
border-radius: 6px;
width: 400px;
font-family: "Menlo", monospace;
letter-spacing: 3px;
text-align: center;
text-transform: lowercase;
outline: none;
}
#word-input:focus { box-shadow: 0 0 16px #ffd700; }
.skip-row {
display: flex;
gap: 12px;
align-items: center;
margin-top: 4px;
}
#skip-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.08);
color: #aaa;
border: 1px solid #555;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
transition: all 0.15s;
}
#skip-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
border-color: #888;
}
.skip-info { color: #888; font-size: 12px; }
/* 卡片栏(DOM)*/
.cards-bar {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: 760px;
justify-content: center;
}
.card {
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
color: white;
padding: 6px 12px;
border-radius: 6px;
font-family: "Menlo", monospace;
font-size: 14px;
border: 2px solid #2a5a8e;
cursor: pointer;
transition: transform 0.1s;
}
.card:hover { transform: scale(1.08); }
.card.selected {
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
color: #000;
border-color: #ff8800;
}
.card .uses { font-size: 11px; opacity: 0.8; }
.card .loaded {
font-size: 10px;
color: #ffd700;
margin-top: 2px;
font-weight: normal;
}
/* 开始战斗按钮 */
.start-battle {
margin-top: 12px;
padding: 14px 32px;
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
color: white;
border: 2px solid #ff9900;
border-radius: 8px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
display: block;
font-family: inherit;
box-shadow: 0 0 16px rgba(255, 122, 0, 0.5);
transition: transform 0.1s;
}
.start-battle:hover { transform: scale(1.05); }
.start-battle:disabled {
background: #555;
border-color: #777;
cursor: not-allowed;
box-shadow: none;
opacity: 0.6;
}
/* 塔商店 */
.shop-bar {
margin-top: 12px;
display: flex;
gap: 10px;
justify-content: center;
}
.shop-tower {
background: linear-gradient(180deg, #4a5a3a 0%, #354525 100%);
color: #fff;
border: 2px solid #6a7a5a;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
text-align: center;
transition: transform 0.1s;
min-width: 100px;
font-size: 13px;
}
.shop-tower:hover:not(.disabled) {
transform: scale(1.05);
border-color: #ffd700;
}
.shop-tower .t-emoji { font-size: 28px; }
.shop-tower .t-name { color: #ffd700; font-weight: bold; margin-top: 2px; }
.shop-tower .t-cost { color: #ffaa00; margin: 2px 0; }
.shop-tower .t-desc { font-size: 11px; color: #aaa; }
.shop-tower.placing {
background: linear-gradient(180deg, #ff7a00 0%, #cc5500 100%);
border-color: #ffd700;
box-shadow: 0 0 20px rgba(255, 215, 0, 0.6);
}
.shop-tower.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tip {
margin-top: 14px;
color: #888;
font-size: 12px;
text-align: center;
max-width: 760px;
line-height: 1.6;
}
</style>
</head>
<body>
<h1>⚔️ 单词塔防 · 穹狼学徒</h1>
<div class="phase-banner" id="phase-banner">🎴 准备阶段:输入单词获得卡片 · 用金币买塔 · 装卡到塔</div>
<div class="hud">
<div class="hp" id="hud-hp">❤️❤️❤️❤️❤️</div>
<div class="gold" id="hud-gold">💰 金币 50</div>
<div class="progress" id="hud-progress">📊 卡片 0/10</div>
</div>
<div id="game"></div>
<!-- 学习阶段面板 -->
<div class="learning-panel" id="learning-panel">
<div class="question" id="question">
<span class="emoji" id="q-emoji">📚</span>
<span id="q-zh">书本</span>
</div>
<input id="word-input" type="text" placeholder="请输入英文..." autofocus autocomplete="off" spellcheck="false">
<div class="skip-row">
<button id="skip-btn" type="button">⏭️ 跳过(不会就跳)</button>
<span class="skip-info" id="skip-info">已跳过 0 次</span>
</div>
</div>
<!-- 塔商店 -->
<div class="shop-bar" id="shop-bar"></div>
<!-- 卡片栏 -->
<div class="cards-bar" id="cards-bar"></div>
<button class="start-battle" id="start-battle-btn" type="button" disabled>
⚔️ 开始战斗(先买塔 + 装卡)
</button>
<div class="tip" id="tip-text">
💡 阶段 1:看中文(+emoji)→ 打英文 → 卡片加入战备库 → 集齐 5 张进入装备阶段(不会的题点跳过)
</div>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.70.0/dist/phaser.min.js"></script>
<script>
// ============================================================
// ASSETS manifest — phaser-2d-game skill 推荐:asset paths NOT embedded everywhere
// ============================================================
const ASSET_BASE = 'assets/kenney-td/PNG/Default size/';
const ASSETS = {
// 地形
grass: ASSET_BASE + 'towerDefense_tile024.png',
// 塔
towerBase: ASSET_BASE + 'towerDefense_tile180.png',
towerTop: ASSET_BASE + 'towerDefense_tile249.png',
// 怪物
enemyPlane: ASSET_BASE + 'towerDefense_tile270.png',
// 子弹
bulletGreen: ASSET_BASE + 'towerDefense_tile245.png',
};
// ============================================================
// 词库 — 学校用品类(12 词)
// ============================================================
const WORDS = [
{en:'book', zh:'书', emoji:'📚'},
{en:'pen', zh:'笔', emoji:'🖊️'},
{en:'desk', zh:'书桌', emoji:'🪑'},
{en:'bag', zh:'书包', emoji:'🎒'},
{en:'ruler', zh:'尺子', emoji:'📏'},
{en:'eraser', zh:'橡皮', emoji:'🧊'},
{en:'paper', zh:'纸', emoji:'📄'},
{en:'pencil', zh:'铅笔', emoji:'✏️'},
{en:'note', zh:'笔记本', emoji:'📓'},
{en:'school', zh:'学校', emoji:'🏫'},
{en:'class', zh:'班级', emoji:'🎓'},
{en:'teacher', zh:'老师', emoji:'👨‍🏫'},
];
// ============================================================
// 游戏配置常量
// ============================================================
const GAME_W = 960;
const GAME_H = 480;
const TARGET_CARDS = 5; // 学习阶段集齐 N 张卡进入装备(测试期 5 张,正式课堂可调 10)
const MONSTERS_TO_WIN = 20;
const INITIAL_HP = 5;
const INITIAL_GOLD = 200; // 起始金币 — 可买 4 个 🧙 或 2 个 ❄️
const CARD_USES = 5; // 一卡 5 发子弹
// 塔商店 — 3 种塔的价格
const TOWER_SHOP = {
magic: { cost: 50, emoji: '🧙', name: '魔法塔', desc: '稳定单发' },
frost: { cost: 80, emoji: '❄️', name: '冰冻塔', desc: '减速 3 秒' },
chain: { cost: 120, emoji: '⚡', name: '链式塔', desc: '链击附近 2 个怪' },
};
// 预设塔位 — 6 个可用位置(沿路径分布,避开路径本身)
const TOWER_SLOT_POSITIONS = [
{x: 180, y: 200},
{x: 360, y: 200},
{x: 360, y: 380},
{x: 530, y: 380},
{x: 700, y: 380},
{x: 770, y: 90},
];
// ============================================================
// 游戏全局状态
// ============================================================
const state = {
phase: 'prepare', // prepare | battle | end
hp: INITIAL_HP,
gold: INITIAL_GOLD,
cards: [], // {id, en, zh, emoji, len, uses, loadedTowers:[]}
towers: [], // {type, x, y, sprite, loadedCardIds, cooldown, lastFireTime, cardLabel, highlight, slotIdx}
towerSlots: [], // {x, y, occupied, marker} — 预设塔位
monsters: [],
monstersKilled: 0,
currentQuestion: null,
scene: null,
cardIdCounter: 0,
skipCount: 0,
selectedCardId: null,
placingTowerType: null, // 'magic' | 'frost' | 'chain' | null — 当前要安装的塔类型
};
// ============================================================
// Phaser scenes
// ============================================================
class BootScene extends Phaser.Scene {
constructor() { super('BootScene'); }
preload() {
// 加载所有 ASSETS
for (const [key, path] of Object.entries(ASSETS)) {
this.load.image(key, path);
}
this.load.on('loaderror', (file) => {
console.error('资源加载失败:', file.src);
});
}
create() {
this.scene.start('BattleScene');
}
}
class BattleScene extends Phaser.Scene {
constructor() { super('BattleScene'); }
create() {
state.scene = this;
// 1) 平铺草地背景
this.drawGrassBackground();
// 2) 画路径
this.path = this.drawPath();
// 3) 画预设塔位标记(灰色圆点,买塔后可安装)
this.placeTowerSlots();
// 4) 启动准备阶段
nextQuestion();
bindLearningInput();
renderShop();
refreshHUD();
refreshStartBattleButton();
}
update(time, delta) {
if (state.phase !== 'battle') return;
// 出怪
if (state.scene.time.now - lastSpawnTime > BATTLE_CONFIG.spawnInterval
&& spawnedCount < MONSTERS_TO_WIN) {
lastSpawnTime = state.scene.time.now;
spawnedCount++;
spawnMonster();
}
// 怪移动
updateMonsters(delta);
// 塔开火
updateTowers(delta);
}
drawGrassBackground() {
// 平铺 64x64 草地 tile
const tileSize = 64;
for (let x = 0; x < GAME_W; x += tileSize) {
for (let y = 0; y < GAME_H; y += tileSize) {
this.add.image(x, y, 'grass').setOrigin(0, 0);
}
}
}
drawPath() {
// Waypoints — 从左侧进入,蛇形,从右侧出
const wp = [
{x: 0, y: 100},
{x: 250, y: 100},
{x: 250, y: 280},
{x: 600, y: 280},
{x: 600, y: 150},
{x: 960, y: 150},
];
const g = this.add.graphics();
g.lineStyle(40, 0x8b6f47, 0.85); // 棕色路径
g.beginPath();
g.moveTo(wp[0].x, wp[0].y);
for (let i = 1; i < wp.length; i++) {
g.lineTo(wp[i].x, wp[i].y);
}
g.strokePath();
// 起点 / 终点标记
this.add.circle(wp[0].x + 20, wp[0].y, 18, 0x00ff00, 0.6); // 起点绿色
this.add.circle(wp[wp.length - 1].x - 20, wp[wp.length - 1].y, 22, 0xff3333, 0.6); // 终点红色
return wp;
}
placeTowerSlots() {
// 画预设塔位标记 — 灰色圆点,玩家买塔后才能在这些位置安装
for (let i = 0; i < TOWER_SLOT_POSITIONS.length; i++) {
const pos = TOWER_SLOT_POSITIONS[i];
// 灰色圆点 — 表示可用位置
const marker = this.add.circle(pos.x, pos.y, 22, 0xaaaaaa, 0.35)
.setStrokeStyle(2, 0xffffff, 0.5);
// 安装时高亮(初始隐藏)
const highlight = this.add.circle(pos.x, pos.y, 26, 0xffd700, 0)
.setStrokeStyle(3, 0xffd700, 0);
// 点击区域
const hitZone = this.add.zone(pos.x, pos.y, 50, 50)
.setInteractive({useHandCursor: true});
const slot = {
x: pos.x, y: pos.y,
occupied: false,
idx: i,
marker, highlight, hitZone,
};
hitZone.on('pointerdown', () => onSlotClick(slot));
state.towerSlots.push(slot);
}
}
}
// ============================================================
// 学习阶段逻辑
// ============================================================
function nextQuestion() {
// 随机选一个未在 cards 里的词
const usedEn = new Set(state.cards.map(c => c.en));
const pool = WORDS.filter(w => !usedEn.has(w.en));
if (pool.length === 0) {
// 词库用完,允许重复
state.currentQuestion = WORDS[Math.floor(Math.random() * WORDS.length)];
} else {
state.currentQuestion = pool[Math.floor(Math.random() * pool.length)];
}
renderQuestion();
}
function renderQuestion() {
const q = state.currentQuestion;
document.getElementById('q-emoji').textContent = q.emoji || '';
document.getElementById('q-zh').textContent = q.zh;
const input = document.getElementById('word-input');
input.value = '';
input.focus();
}
function bindLearningInput() {
const input = document.getElementById('word-input');
input.addEventListener('input', () => {
if (state.phase === 'end') return;
const val = input.value.trim().toLowerCase();
if (val === state.currentQuestion.en) {
acceptCard(state.currentQuestion);
input.value = '';
nextQuestion(); // 输入框始终活跃,不再自动切阶段
}
});
// 跳过按钮 — 不会的词,直接换下一题
document.getElementById('skip-btn').addEventListener('click', () => {
if (state.phase === 'end') return;
state.skipCount += 1;
document.getElementById('skip-info').textContent = `已跳过 ${state.skipCount}`;
input.value = '';
nextQuestion();
input.focus();
});
// 开始战斗按钮
document.getElementById('start-battle-btn').addEventListener('click', startBattle);
}
function acceptCard(word) {
const card = {
id: ++state.cardIdCounter,
en: word.en,
zh: word.zh,
emoji: word.emoji,
len: word.en.length,
uses: CARD_USES,
loadedTowers: [],
};
state.cards.push(card);
renderCardsBar();
refreshHUD();
refreshStartBattleButton();
}
function renderCardsBar() {
const bar = document.getElementById('cards-bar');
bar.innerHTML = '';
// 只显示未装到任何塔的卡(装上就从栏里消失)
const unloadedCards = state.cards.filter(c => c.loadedTowers.length === 0);
for (const c of unloadedCards) {
const div = document.createElement('div');
div.className = 'card' + (state.selectedCardId === c.id ? ' selected' : '');
div.dataset.cardId = c.id;
div.innerHTML = `${c.emoji} ${c.en}<div class="uses">×${c.uses} 发</div>`;
div.addEventListener('click', () => onCardClick(c));
bar.appendChild(div);
}
}
// ============================================================
// 塔商店:买塔 + 选位置安装
// ============================================================
function onShopBuyClick(type) {
if (state.phase === 'end') return;
const def = TOWER_SHOP[type];
if (state.gold < def.cost) return;
if (state.placingTowerType === type) {
// 已在安装模式且点同一个 → 取消
cancelPlacing();
return;
}
// 进入安装模式(还未扣金币,等放下才扣)
state.placingTowerType = type;
highlightTowerSlots(true);
renderShop();
}
function cancelPlacing() {
state.placingTowerType = null;
highlightTowerSlots(false);
renderShop();
}
function highlightTowerSlots(on) {
for (const slot of state.towerSlots) {
if (slot.occupied) continue;
slot.highlight.setStrokeStyle(3, 0xffd700, on ? 0.9 : 0);
}
}
function onSlotClick(slot) {
if (state.phase === 'end') return;
if (!state.placingTowerType) return;
if (slot.occupied) return;
// 扣金币 + 建塔
const def = TOWER_SHOP[state.placingTowerType];
state.gold -= def.cost;
buildTower(state.placingTowerType, slot);
cancelPlacing();
refreshHUD();
renderShop();
refreshStartBattleButton();
}
function buildTower(type, slot) {
const tintMap = { magic: 0xffffff, frost: 0x88ccff, chain: 0xff88ff };
const scene = state.scene;
// 塔底座
const base = scene.add.image(slot.x, slot.y + 6, 'towerBase').setScale(0.85);
// 塔顶
const top = scene.add.image(slot.x, slot.y - 4, 'towerTop').setScale(0.85);
top.setTint(tintMap[type]);
// 类型标签
const def = TOWER_SHOP[type];
scene.add.text(slot.x, slot.y + 38, `${def.emoji} ${def.name.replace('塔','')}`, {
fontSize: '12px',
color: '#fff',
backgroundColor: '#00000099',
padding: { x: 4, y: 2 }
}).setOrigin(0.5);
// 装入卡显示
const cardLabel = scene.add.text(slot.x, slot.y - 42, '空', {
fontSize: '12px',
color: '#000',
backgroundColor: '#666',
padding: { x: 4, y: 2 }
}).setOrigin(0.5);
// 选卡高亮(选中卡片时显示)
const highlight = scene.add.circle(slot.x, slot.y, 36, 0xffd700, 0)
.setStrokeStyle(3, 0xffd700, 0);
// 隐藏 slot 的 marker
slot.marker.setVisible(false);
slot.highlight.setStrokeStyle(0, 0, 0);
slot.occupied = true;
const tower = {
type, x: slot.x, y: slot.y,
sprite: top, base,
loadedCardIds: [],
cooldown: type === 'chain' ? 1400 : 1000,
lastFireTime: 0,
cardLabel, highlight, slotIdx: slot.idx,
};
// 点击塔(装卡)
slot.hitZone.removeAllListeners('pointerdown');
slot.hitZone.on('pointerdown', () => onTowerClick(tower));
state.towers.push(tower);
}
function renderShop() {
const shop = document.getElementById('shop-bar');
shop.innerHTML = '';
for (const [type, def] of Object.entries(TOWER_SHOP)) {
const canAfford = state.gold >= def.cost;
const isPlacing = state.placingTowerType === type;
const div = document.createElement('div');
div.className = 'shop-tower' +
(isPlacing ? ' placing' : '') +
(!canAfford ? ' disabled' : '');
div.innerHTML = `
<div class="t-emoji">${def.emoji}</div>
<div class="t-name">${def.name}</div>
<div class="t-cost">💰${def.cost}</div>
<div class="t-desc">${def.desc}</div>
`;
if (canAfford) div.addEventListener('click', () => onShopBuyClick(type));
shop.appendChild(div);
}
}
// ============================================================
// 装备交互:点卡 → 点塔
// ============================================================
function onCardClick(card) {
if (state.phase === 'end') return;
// 已选中同一卡 → 取消选中
if (state.selectedCardId === card.id) {
state.selectedCardId = null;
} else {
state.selectedCardId = card.id;
}
renderCardsBar();
refreshTowerHighlights();
}
function onTowerClick(tower) {
if (state.phase === 'end') return;
if (state.selectedCardId == null) return;
const card = state.cards.find(c => c.id === state.selectedCardId);
if (!card) return;
// 装入(若未装)
if (!tower.loadedCardIds.includes(card.id)) {
tower.loadedCardIds.push(card.id);
if (!card.loadedTowers.includes(tower.type)) {
card.loadedTowers.push(tower.type);
}
}
updateTowerLabel(tower);
// 取消选中
state.selectedCardId = null;
renderCardsBar();
refreshTowerHighlights();
refreshStartBattleButton();
refreshHUD();
}
function updateTowerLabel(tower) {
// 统计:N 词(装入的不同卡数,uses > 0) + M 发(总剩余子弹)
const activeCards = tower.loadedCardIds
.map(id => state.cards.find(c => c.id === id))
.filter(c => c && c.uses > 0);
// 清理已用完的卡 — 从 loadedCardIds 移除
tower.loadedCardIds = activeCards.map(c => c.id);
if (activeCards.length === 0) {
tower.cardLabel.setText('空');
tower.cardLabel.setBackgroundColor('#666');
return;
}
const totalUses = activeCards.reduce((sum, c) => sum + c.uses, 0);
tower.cardLabel.setText(`${activeCards.length}词·${totalUses}`);
tower.cardLabel.setBackgroundColor('#ffd700');
}
function refreshTowerHighlights() {
const hasSelection = state.selectedCardId != null;
for (const t of state.towers) {
t.highlight.setStrokeStyle(3, 0xffd700, hasSelection ? 0.9 : 0);
}
}
function refreshStartBattleButton() {
const btn = document.getElementById('start-battle-btn');
if (state.phase === 'battle' || state.phase === 'end') {
btn.style.display = 'none';
return;
}
btn.style.display = 'block';
const anyLoaded = state.towers.some(t => t.loadedCardIds.length > 0);
btn.disabled = !anyLoaded;
if (state.towers.length === 0) {
btn.textContent = '⚔️ 开始战斗(先买塔)';
} else if (!anyLoaded) {
btn.textContent = '⚔️ 开始战斗(给塔装卡)';
} else {
btn.textContent = '⚔️ 开始战斗 ▶';
}
}
// ============================================================
// 战斗阶段:启动 / 主循环 / 塔开火 / 怪物 / 子弹 / 命中
// ============================================================
const BATTLE_CONFIG = {
monsterSpeed: 40, // 怪物速度 px/s(慢一些,有反应时间)
monsterFrozenSpeed: 15, // 冰冻后速度
monsterHp: 1, // 怪 HP(1 → 一炮干掉,降低命中率影响)
spawnInterval: 2500, // 出怪间隔 ms(2.5 秒一只)
bulletDamage: 1, // 基础伤害
bulletSpeed: 500, // 子弹飞行速度 px/s
towerRange: 220, // 塔射程
frostDuration: 3000, // 冰冻持续 ms
chainExtraTargets: 2, // 链式塔额外目标数
};
let battleStartTime = 0;
let lastSpawnTime = 0;
let spawnedCount = 0;
function startBattle() {
if (state.phase !== 'prepare') return;
state.phase = 'battle';
document.getElementById('phase-banner').textContent =
'⚔️ 战斗阶段:塔自动攻击 · 你可继续输入补卡 · 继续点卡装塔';
document.getElementById('start-battle-btn').style.display = 'none';
battleStartTime = state.scene.time.now;
lastSpawnTime = battleStartTime - BATTLE_CONFIG.spawnInterval; // 立刻出第一只
spawnedCount = 0;
refreshHUD();
}
function spawnMonster() {
const path = state.scene.path;
const startWp = path[0];
const sprite = state.scene.add.image(startWp.x, startWp.y, 'enemyPlane')
.setScale(0.7);
// HP 条 graphics
const hpBar = state.scene.add.rectangle(startWp.x, startWp.y - 22, 32, 4, 0x00ff00);
hpBar.setStrokeStyle(1, 0x000000);
const monster = {
id: Math.random().toString(36).slice(2),
sprite, hpBar,
hp: BATTLE_CONFIG.monsterHp,
maxHp: BATTLE_CONFIG.monsterHp,
pathIndex: 0, // 当前到了哪段
segmentProgress: 0, // 0-1 当前段进度
speed: BATTLE_CONFIG.monsterSpeed,
frozenUntil: 0,
dead: false,
};
state.monsters.push(monster);
}
function updateMonsters(dt) {
const now = state.scene.time.now;
const path = state.scene.path;
for (let i = state.monsters.length - 1; i >= 0; i--) {
const m = state.monsters[i];
if (m.dead) continue;
// 解除冰冻
const speed = (now < m.frozenUntil) ? BATTLE_CONFIG.monsterFrozenSpeed : m.speed;
// 路径插值
if (m.pathIndex >= path.length - 1) {
// 到家
onMonsterReachEnd(m);
state.monsters.splice(i, 1);
continue;
}
const a = path[m.pathIndex];
const b = path[m.pathIndex + 1];
const segmentLen = Phaser.Math.Distance.Between(a.x, a.y, b.x, b.y);
m.segmentProgress += (speed * dt / 1000) / segmentLen;
if (m.segmentProgress >= 1) {
m.pathIndex++;
m.segmentProgress = 0;
}
const t = m.segmentProgress;
const nx = Phaser.Math.Linear(a.x, b.x, t);
const ny = Phaser.Math.Linear(a.y, b.y, t);
m.sprite.setPosition(nx, ny);
m.hpBar.setPosition(nx, ny - 22);
// HP 条颜色按比例
const hpRatio = m.hp / m.maxHp;
m.hpBar.setSize(32 * hpRatio, 4);
m.hpBar.setFillStyle(hpRatio > 0.5 ? 0x00ff00 : hpRatio > 0.25 ? 0xffff00 : 0xff0000);
// 冰冻视觉
m.sprite.setTint(now < m.frozenUntil ? 0x88ccff : 0xffffff);
}
}
function updateTowers(dt) {
const now = state.scene.time.now;
for (const tower of state.towers) {
if (now - tower.lastFireTime < tower.cooldown) continue;
// 找一张还有 uses 的卡
const card = tower.loadedCardIds
.map(id => state.cards.find(c => c.id === id))
.find(c => c && c.uses > 0);
if (!card) continue;
// 找射程内最近的活怪
let target = null;
let bestDist = Infinity;
for (const m of state.monsters) {
if (m.dead) continue;
const d = Phaser.Math.Distance.Between(tower.x, tower.y, m.sprite.x, m.sprite.y);
if (d <= BATTLE_CONFIG.towerRange && d < bestDist) {
bestDist = d;
target = m;
}
}
if (!target) continue;
// 开火 — 消耗一发 uses
card.uses -= 1;
tower.lastFireTime = now;
fireBullet(tower, target, card);
if (card.uses === 0) {
updateTowerLabel(tower); // 刷新塔头
renderCardsBar();
}
}
}
function fireBullet(tower, target, card) {
const tintMap = { magic: 0xffffff, frost: 0x88ccff, chain: 0xff88ff };
const bullet = state.scene.add.image(tower.x, tower.y - 10, 'bulletGreen')
.setScale(0.6)
.setTint(tintMap[tower.type]);
state.scene.tweens.add({
targets: bullet,
x: target.sprite.x,
y: target.sprite.y,
duration: 300,
onComplete: () => {
bullet.destroy();
if (!target.dead) {
applyDamage(target, BATTLE_CONFIG.bulletDamage, tower.type);
}
}
});
}
function applyDamage(monster, damage, towerType) {
if (monster.dead) return;
monster.hp -= damage;
// 状态效果
if (towerType === 'frost') {
monster.frozenUntil = state.scene.time.now + BATTLE_CONFIG.frostDuration;
}
// 命中粒子
spawnHitParticles(monster.sprite.x, monster.sprite.y, towerType);
// 死亡
if (monster.hp <= 0) {
onMonsterDeath(monster);
// 链式塔:额外打附近 2 个怪
if (towerType === 'chain') {
chainAttack(monster);
}
} else if (towerType === 'chain') {
chainAttack(monster);
}
}
function chainAttack(initialMonster) {
// 找最近 2 个怪
const others = state.monsters
.filter(m => !m.dead && m !== initialMonster)
.map(m => ({m, d: Phaser.Math.Distance.Between(
initialMonster.sprite.x, initialMonster.sprite.y,
m.sprite.x, m.sprite.y
)}))
.filter(o => o.d < 150)
.sort((a, b) => a.d - b.d)
.slice(0, BATTLE_CONFIG.chainExtraTargets);
for (const { m } of others) {
// 画链式雷电线
const g = state.scene.add.graphics();
g.lineStyle(2, 0xff88ff, 0.9);
g.lineBetween(initialMonster.sprite.x, initialMonster.sprite.y, m.sprite.x, m.sprite.y);
state.scene.tweens.add({
targets: g, alpha: 0, duration: 250,
onComplete: () => g.destroy()
});
// 链伤(避免无限递归 — 调用 applyDamage 但传 'chain-no-rebound' 防止再次连锁)
if (!m.dead) {
m.hp -= BATTLE_CONFIG.bulletDamage;
spawnHitParticles(m.sprite.x, m.sprite.y, 'chain');
if (m.hp <= 0) onMonsterDeath(m);
}
}
}
function spawnHitParticles(x, y, type) {
const colorMap = { magic: 0xffd700, frost: 0x88ccff, chain: 0xff88ff };
const color = colorMap[type];
for (let i = 0; i < 6; i++) {
const angle = (Math.PI * 2 * i) / 6;
const p = state.scene.add.circle(x, y, 3, color);
state.scene.tweens.add({
targets: p,
x: x + Math.cos(angle) * 24,
y: y + Math.sin(angle) * 24,
alpha: 0,
duration: 300,
onComplete: () => p.destroy()
});
}
}
function onMonsterDeath(monster) {
if (monster.dead) return;
monster.dead = true;
state.monstersKilled += 1;
state.gold += 10; // 击杀加金币 — 用于继续买塔
// 爆炸特效
spawnHitParticles(monster.sprite.x, monster.sprite.y, 'magic');
monster.sprite.destroy();
monster.hpBar.destroy();
refreshHUD();
renderShop(); // 金币变了,刷新商店买得起的状态
checkWinLose();
}
function onMonsterReachEnd(monster) {
state.hp -= 1;
monster.sprite.destroy();
monster.hpBar.destroy();
// 红屏 flash
const flash = state.scene.add.rectangle(GAME_W/2, GAME_H/2, GAME_W, GAME_H, 0xff0000, 0.3);
state.scene.tweens.add({
targets: flash, alpha: 0, duration: 300,
onComplete: () => flash.destroy()
});
refreshHUD();
checkWinLose();
}
function checkWinLose() {
if (state.phase !== 'battle') return;
if (state.hp <= 0) {
state.phase = 'end';
document.getElementById('phase-banner').textContent =
`💀 GAME OVER · 击杀 ${state.monstersKilled} 个怪物`;
} else if (state.monstersKilled >= MONSTERS_TO_WIN) {
state.phase = 'end';
document.getElementById('phase-banner').textContent =
`🏆 完美胜利 · 击杀 ${state.monstersKilled} 个 · 跳过 ${state.skipCount} 次 · 剩余 HP ${state.hp}`;
}
}
// ============================================================
// HUD
// ============================================================
function refreshHUD() {
document.getElementById('hud-hp').textContent =
'❤️'.repeat(Math.max(0, state.hp)) + '🖤'.repeat(INITIAL_HP - state.hp);
document.getElementById('hud-gold').textContent = `💰 金币 ${state.gold}`;
const unloadedCount = state.cards.filter(c => c.loadedTowers.length === 0).length;
const totalTowers = state.towers.length;
const loadedTowers = state.towers.filter(t => t.loadedCardIds.length > 0).length;
document.getElementById('hud-progress').textContent =
state.phase === 'battle'
? `⚔️ 击杀 ${state.monstersKilled}/${MONSTERS_TO_WIN} · 卡 ${unloadedCount} · 塔 ${loadedTowers}/${totalTowers}`
: `🎴 未装卡 ${unloadedCount} · 塔 ${totalTowers}(装卡 ${loadedTowers})`;
}
// ============================================================
// Phaser game 启动
// ============================================================
const config = {
type: Phaser.AUTO,
width: GAME_W,
height: GAME_H,
parent: 'game',
backgroundColor: '#2a3a2a',
pixelArt: false,
scene: [BootScene, BattleScene],
};
new Phaser.Game(config);
</script>
</body>
</html>