## 主要变更 ### 课程设计 - 大纲扩展到 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>
1011 lines
30 KiB
HTML
1011 lines
30 KiB
HTML
<!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>
|