第7篇 | 基于 HarmonyOS 开发战旗小游戏项目游戏主页面

前言:

游戏主界面GamePage.ets介绍,主要采用Canvas 2D 渲染系统--绘制地图、单位与血条

代码如下:

TypeScript 复制代码
import router from '@ohos.router';
import { GameEngine } from '../engine/GameEngine';
import { AIEngine } from '../ai/AIEngine';
import { Unit, Faction, Position, GameState } from '../common/GameModels';
import { MAP_WIDTH, MAP_HEIGHT, TILE_SIZE, TerrainType, UnitType, GamePhase,
  TERRAIN_COLORS, TERRAIN_NAMES, UNIT_NAMES,
  UNIT_COLOR_PLAYER, UNIT_COLOR_ENEMY, SELECTED_COLOR,
  MOVABLE_COLOR, ATTACKABLE_COLOR } from '../common/GameConstants';

@Entry
@Component
struct GamePage {
  // 游戏状态
  private gameEngine: GameEngine = new GameEngine(new GameState());
  private aiEngine: AIEngine = new AIEngine(this.gameEngine);

  @State gameState: GameState = this.gameEngine.getGameState();
  @State isAITurn: boolean = false;
  @State gameMessage: string = '我方回合 - 请选择单位';
  @State showRules: boolean = false;

  // Canvas上下文
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

  aboutToAppear() {
    // 初始化游戏
    this.gameEngine.initMap();
    this.gameEngine.initUnits();
    this.gameState = this.gameEngine.getGameState();
  }

  build() {
    Column() {
      // 顶部信息栏
      this.buildTopBar()

      // 游戏地图(Canvas)
      Canvas(this.ctx)
        .width(MAP_WIDTH * TILE_SIZE)
        .height(MAP_HEIGHT * TILE_SIZE)
        .backgroundColor('#87CEEB')
        .onReady(() => {
          this.drawGameMap();
        })
        .onTouch((event: TouchEvent) => {
          this.handleMapTouch(event);
        })
        .margin(10)

      // 底部操作栏
      this.buildBottomBar()

      // 回合信息
      this.buildTurnInfo()

      // 游戏规则弹窗
      if (this.showRules) {
        this.buildRulesDialog()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5DEB3')
    .padding(10)
  }

  // 顶部信息栏
  @Builder
  buildTopBar() {
    Row() {
      Text('远古帝国战旗')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#8B4513')

      Blank()

      Button('返回')
        .height(30)
        .backgroundColor('#A0522D')
        .onClick(() => {
          router.back();
        })
    }
    .width('100%')
    .margin({ bottom: 10 })
  }

  // 底部操作栏
  @Builder
  buildBottomBar() {
    Row({ space: 10 }) {
      Button('结束回合')
        .height(40)
        .backgroundColor('#8B4513')
        .enabled(!this.isAITurn && this.gameState.currentPhase === GamePhase.PLAYER_TURN)
        .onClick(() => {
          this.endPlayerTurn();
        })

      Button('游戏规则')
        .height(40)
        .backgroundColor('#A0522D')
        .onClick(() => {
          this.showRules = true;
        })
    }
    .margin({ top: 10, bottom: 10 })
  }

  // 回合信息
  @Builder
  buildTurnInfo() {
    Column() {
      Text(this.gameMessage)
        .fontSize(16)
        .fontColor(this.isAITurn ? '#DC143C' : '#4169E1')
        .textAlign(TextAlign.Center)

      Text(`回合: ${this.gameState.turnCount}`)
        .fontSize(14)
        .fontColor('#666666')
        .margin({ top: 5 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor('#FFF8DC')
    .borderRadius(8)
  }

  // 游戏规则弹窗
  @Builder
  buildRulesDialog() {
    Column() {
      Text('游戏规则')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 15 })

      Text('1. 这是一款回合制战旗游戏')
        .fontSize(14)
        .margin({ bottom: 5 })

      Text('2. 点击我方单位选中,绿色格子可移动')
        .fontSize(14)
        .margin({ bottom: 5 })

      Text('3. 红色格子可攻击敌方单位')
        .fontSize(14)
        .margin({ bottom: 5 })

      Text('4. 每个单位每回合可以移动和攻击一次')
        .fontSize(14)
        .margin({ bottom: 5 })

      Text('5. 消灭所有敌方单位获得胜利')
        .fontSize(14)
        .margin({ bottom: 15 })

      Text('兵种介绍:')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 5 })

      Text('战士:均衡型,适合前线作战')
        .fontSize(13)
        .margin({ bottom: 3 })

      Text('弓箭手:远程攻击,脆弱但输出高')
        .fontSize(13)
        .margin({ bottom: 3 })

      Text('骑兵:高机动,适合快速突袭')
        .fontSize(13)
        .margin({ bottom: 3 })

      Text('法师:魔法攻击,范围伤害')
        .fontSize(13)
        .margin({ bottom: 3 })

      Text('将军:高属性,率领部队')
        .fontSize(13)
        .margin({ bottom: 15 })

      Button('关闭')
        .width(100)
        .height(35)
        .backgroundColor('#8B4513')
        .onClick(() => {
          this.showRules = false;
        })
    }
    .width('80%')
    .padding(20)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .position({ x: '10%', y: '20%' })
    .zIndex(999)
  }

  // 绘制游戏地图
  drawGameMap(): void {
    const ctx = this.ctx;
    const state = this.gameState;

    // 清空画布
    ctx.clearRect(0, 0, MAP_WIDTH * TILE_SIZE, MAP_HEIGHT * TILE_SIZE);

    // 绘制地形
    for (let y = 0; y < MAP_HEIGHT; y++) {
      for (let x = 0; x < MAP_WIDTH; x++) {
        const terrain = state.map[y][x].terrain;
        const color = TERRAIN_COLORS[terrain];

        // 绘制格子
        ctx.fillStyle = color;
        ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

        // 绘制网格线
        ctx.strokeStyle = '#000000';
        ctx.lineWidth = 1;
        ctx.strokeRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

        // 绘制地形符号
        this.drawTerrainSymbol(ctx, x, y, terrain);
      }
    }

    // 绘制可移动范围
    if (state.selectedUnitId !== null) {
      for (const pos of state.movablePositions) {
        ctx.fillStyle = MOVABLE_COLOR + '80'; // 半透明
        ctx.fillRect(pos.x * TILE_SIZE, pos.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
      }

      // 绘制可攻击范围
      for (const pos of state.attackablePositions) {
        ctx.fillStyle = ATTACKABLE_COLOR + '80';
        ctx.fillRect(pos.x * TILE_SIZE, pos.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
      }
    }

    // 绘制单位
    for (const unit of state.units) {
      if (!unit.isAlive) continue;
      this.drawUnit(ctx, unit);
    }

    // 绘制选中效果
    if (state.selectedUnitId !== null) {
      const unit = this.gameEngine.getUnitById(state.selectedUnitId);
      if (unit) {
        ctx.strokeStyle = SELECTED_COLOR;
        ctx.lineWidth = 3;
        ctx.strokeRect(
          unit.position.x * TILE_SIZE + 2,
          unit.position.y * TILE_SIZE + 2,
          TILE_SIZE - 4,
          TILE_SIZE - 4
        );
      }
    }
  }

  // 绘制地形符号
  private drawTerrainSymbol(ctx: CanvasRenderingContext2D, x: number, y: number, terrain: TerrainType): void {
    const cx = x * TILE_SIZE + TILE_SIZE / 2;
    const cy = y * TILE_SIZE + TILE_SIZE / 2;

    ctx.font = '12px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillStyle = '#000000';

    switch (terrain) {
      case TerrainType.FOREST:
        ctx.fillText('🌲', cx, cy);
        break;
      case TerrainType.MOUNTAIN:
        ctx.fillText('⛰️', cx, cy);
        break;
      case TerrainType.WATER:
        ctx.fillText('💧', cx, cy);
        break;
      case TerrainType.ROAD:
        ctx.fillText('🛤️', cx, cy);
        break;
    }
  }

  // 绘制单位
  private drawUnit(ctx: CanvasRenderingContext2D, unit: Unit): void {
    const x = unit.position.x * TILE_SIZE;
    const y = unit.position.y * TILE_SIZE;
    const cx = x + TILE_SIZE / 2;
    const cy = y + TILE_SIZE / 2;

    // 绘制单位背景(圆形)
    ctx.beginPath();
    ctx.arc(cx, cy, TILE_SIZE / 2 - 8, 0, Math.PI * 2);
    ctx.fillStyle = unit.faction === Faction.PLAYER ? UNIT_COLOR_PLAYER : UNIT_COLOR_ENEMY;
    ctx.fill();
    ctx.strokeStyle = '#000000';
    ctx.lineWidth = 2;
    ctx.stroke();

    // 绘制单位符号
    ctx.font = '20px Arial';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';

    const symbols = ['⚔️', '🏹', '🐎', '🔮', '👑'];
    ctx.fillText(symbols[unit.type], cx, cy - 5);

    // 绘制血条
    const barWidth = TILE_SIZE - 16;
    const barHeight = 4;
    const barX = x + 8;
    const barY = y + TILE_SIZE - 10;

    // 背景
    ctx.fillStyle = '#000000';
    ctx.fillRect(barX, barY, barWidth, barHeight);

    // 当前血量
    const hpPercent = unit.hp / unit.maxHp;
    ctx.fillStyle = hpPercent > 0.5 ? '#00FF00' : hpPercent > 0.25 ? '#FFFF00' : '#FF0000';
    ctx.fillRect(barX, barY, barWidth * hpPercent, barHeight);

    // 绘制血条边框
    ctx.strokeStyle = '#000000';
    ctx.lineWidth = 1;
    ctx.strokeRect(barX, barY, barWidth, barHeight);
  }

  // 处理地图触摸事件
  handleMapTouch(event: TouchEvent): void {
    if (this.isAITurn) return;
    if (this.gameState.currentPhase !== GamePhase.PLAYER_TURN) return;

    const touch = event.touches[0];
    if (!touch) return;

    // 计算点击的格子坐标
    const rect = touch;
    const x = Math.floor(rect.x / TILE_SIZE);
    const y = Math.floor(rect.y / TILE_SIZE);

    if (!this.gameEngine.isValidPosition({ x, y })) return;

    const clickedUnit = this.gameEngine.getUnitAt({ x, y });

    // 如果有单位被选中
    if (this.gameState.selectedUnitId !== null) {
      const selectedUnit = this.gameEngine.getUnitById(this.gameState.selectedUnitId);

      if (!selectedUnit) {
        this.gameEngine.deselectUnit();
        this.gameState = this.gameEngine.getGameState();
        this.drawGameMap();
        return;
      }

      // 点击可移动格子
      const isMovable = this.gameState.movablePositions.some(p => p.x === x && p.y === y);
      if (isMovable) {
        const success = this.gameEngine.moveUnit(selectedUnit, { x, y });
        if (success) {
          this.gameEngine.deselectUnit();
          this.gameState = this.gameEngine.getGameState();
          this.drawGameMap();
          this.checkGameEnd();
          return;
        }
      }

      // 点击可攻击格子
      const isAttackable = this.gameState.attackablePositions.some(p => p.x === x && p.y === y);
      if (isAttackable) {
        const result = this.gameEngine.attackUnit(selectedUnit, { x, y });
        if (result.success) {
          this.gameMessage = `攻击造成 ${Math.floor(result.damage)} 点伤害!`;
          this.gameEngine.deselectUnit();
          this.gameState = this.gameEngine.getGameState();
          this.drawGameMap();
          this.checkGameEnd();
          return;
        }
      }

      // 点击其他我方单位 - 切换选择
      if (clickedUnit && clickedUnit.faction === Faction.PLAYER && !clickedUnit.hasActed) {
        this.gameEngine.selectUnit(clickedUnit.id);
        this.gameState = this.gameEngine.getGameState();
        this.drawGameMap();
        return;
      }

      // 点击空白或其他 - 取消选择
      this.gameEngine.deselectUnit();
      this.gameState = this.gameEngine.getGameState();
      this.drawGameMap();
    } else {
      // 没有单位被选中,尝试选择我方单位
      if (clickedUnit && clickedUnit.faction === Faction.PLAYER && !clickedUnit.hasActed) {
        this.gameEngine.selectUnit(clickedUnit.id);
        this.gameState = this.gameEngine.getGameState();
        this.drawGameMap();
        this.gameMessage = `选中${UNIT_NAMES[clickedUnit.type]},绿色可移动,红色可攻击`;
      }
    }
  }

  // 结束玩家回合
  endPlayerTurn(): void {
    this.gameEngine.deselectUnit();
    this.gameEngine.endTurn();
    this.gameState = this.gameEngine.getGameState();
    this.isAITurn = true;
    this.gameMessage = '敌方回合...';
    this.drawGameMap();

    // 执行AI回合
    setTimeout(() => {
      this.executeAITurn();
    }, 500);
  }

  // 执行AI回合
  executeAITurn(): void {
    this.aiEngine.executeTurn().then(() => {
      // AI回合结束,切换回玩家回合
      this.gameEngine.endTurn();
      this.gameState = this.gameEngine.getGameState();
      this.isAITurn = false;
      this.gameMessage = '我方回合 - 请选择单位';
      this.drawGameMap();
      this.checkGameEnd();
    });
  }

  // 检查游戏是否结束
  checkGameEnd(): void {
    const result = this.gameEngine.checkGameEnd();
    if (result === GamePhase.VICTORY) {
      this.gameMessage = '🎉 胜利!你消灭了所有敌人!';
      this.showGameEndDialog(true);
    } else if (result === GamePhase.DEFEAT) {
      this.gameMessage = '💀 失败!你的部队被全歼了...';
      this.showGameEndDialog(false);
    }
  }

  // 显示游戏结束对话框
  showGameEndDialog(isVictory: boolean): void {
    // 这里可以实现一个更美观的对话框
    // 暂时使用简单的提示
    setTimeout(() => {
      if (isVictory) {
        this.gameMessage = '🎉 胜利!点击返回重新开始';
      } else {
        this.gameMessage = '💀 失败!点击返回重新开始';
      }
    }, 1000);
  }
}

一、为什么选择 Canvas

ArkUI 提供了两种游戏画面实现方案:

| 方案 | 优点 | 缺点 |

|------|------|------|

| **ArkUI 组件布局** | 声明式、自动刷新 | 性能差,复杂动画困难 |

| **Canvas 2D API** | 精确控制、性能好 | 命令式编程,需手动刷新 |

战棋游戏的地图是动态的(高亮格子随选择变化),需要频繁重绘,Canvas 2D 是更合适的选择。

二、Canvas 初始化

```

// GamePage.ets

private settings: RenderingContextSettings = new RenderingContextSettings(true); // true = 开启抗锯齿

private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);

```

在 `build()` 方法中嵌入 Canvas 组件:

```

Canvas(this.ctx)

.width(MAP_WIDTH * TILE_SIZE) // 9 × 64 = 576px

.height(MAP_HEIGHT * TILE_SIZE) // 8 × 64 = 512px

.backgroundColor('#87CEEB') // 天蓝色背景(万一有空白区域)

.onReady(() => {

this.drawGameMap(); // Canvas 准备好后立即绘制

})

.onTouch((event: TouchEvent) => {

this.handleMapTouch(event); // 触摸事件处理

})

.margin(10)

```

**`onReady` 回调**:Canvas 组件挂载到 DOM 后触发,确保在真正可以绘制之前不执行任何绘制操作。

三、主渲染函数:drawGameMap

```

drawGameMap(): void {

const ctx = this.ctx;

const state = this.gameState;

// 第1步:清空画布

ctx.clearRect(0, 0, MAP_WIDTH * TILE_SIZE, MAP_HEIGHT * TILE_SIZE);

// 第2步:绘制地形层

for (let y = 0; y < MAP_HEIGHT; y++) {

for (let x = 0; x < MAP_WIDTH; x++) {

const terrain = state.mapyx.terrain;

// 绘制填色格子

ctx.fillStyle = TERRAIN_COLORSterrain;

ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

// 绘制网格线

ctx.strokeStyle = '#000000';

ctx.lineWidth = 1;

ctx.strokeRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

// 绘制地形 Emoji 符号

this.drawTerrainSymbol(ctx, x, y, terrain);

}

}

// 第3步:绘制高亮层(可移动/攻击范围)

if (state.selectedUnitId !== null) {

for (const pos of state.movablePositions) {

ctx.fillStyle = MOVABLE_COLOR + '80'; // '#7CFC0080' 半透明

ctx.fillRect(pos.x * TILE_SIZE, pos.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

}

for (const pos of state.attackablePositions) {

ctx.fillStyle = ATTACKABLE_COLOR + '80'; // '#FF634780' 半透明

ctx.fillRect(pos.x * TILE_SIZE, pos.y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

}

}

// 第4步:绘制单位层

for (const unit of state.units) {

if (!unit.isAlive) continue; // 跳过死亡单位

this.drawUnit(ctx, unit);

}

// 第5步:绘制选中边框(最上层)

if (state.selectedUnitId !== null) {

const unit = this.gameEngine.getUnitById(state.selectedUnitId);

if (unit) {

ctx.strokeStyle = SELECTED_COLOR; // '#FFD700' 金色

ctx.lineWidth = 3;

ctx.strokeRect(

unit.position.x * TILE_SIZE + 2,

unit.position.y * TILE_SIZE + 2,

TILE_SIZE - 4,

TILE_SIZE - 4

);

}

}

}

```

分层绘制是关键

Canvas 是**画家算法**(Painter's Algorithm):后画的覆盖先画的。绘制顺序决定了视觉层次:

```

地形层(最底层)

↓ 覆盖

高亮层(半透明遮罩)

↓ 覆盖

单位层(圆形 + Emoji)

↓ 覆盖

选中边框(最顶层)

```

四、地形符号绘制

```

private drawTerrainSymbol(

ctx: CanvasRenderingContext2D,

x: number, y: number,

terrain: TerrainType

): void {

const cx = x * TILE_SIZE + TILE_SIZE / 2; // 格子中心 X

const cy = y * TILE_SIZE + TILE_SIZE / 2; // 格子中心 Y

ctx.font = '12px Arial';

ctx.textAlign = 'center';

ctx.textBaseline = 'middle';

ctx.fillStyle = '#000000';

switch (terrain) {

case TerrainType.FOREST: ctx.fillText('🌲', cx, cy); break;

case TerrainType.MOUNTAIN: ctx.fillText('⛰️', cx, cy); break;

case TerrainType.WATER: ctx.fillText('💧', cx, cy); break;

case TerrainType.ROAD: ctx.fillText('🛤️', cx, cy); break;

// 平原不绘制符号(保持简洁)

}

}

```

知识点:Canvas 文本对齐

`textAlign = 'center'` + `textBaseline = 'middle'` 组合,使文本以 `(cx, cy)` 为中心点绘制,这是在格子中居中显示 Emoji 的标准做法。

Emoji 在 Canvas 中的限制:

不同设备上 Emoji 的渲染尺寸和位置可能略有差异。`font = '12px Arial'` 设置了字号,但 Emoji 的实际尺寸由系统字体决定。在生产项目中,建议使用精灵图(Sprite Sheet)替代 Emoji 以确保跨设备一致性。

五、单位绘制:圆形 + Emoji + 血条

```

private drawUnit(ctx: CanvasRenderingContext2D, unit: Unit): void {

const x = unit.position.x * TILE_SIZE;

const y = unit.position.y * TILE_SIZE;

const cx = x + TILE_SIZE / 2; // 格子中心 X

const cy = y + TILE_SIZE / 2; // 格子中心 Y

// === 绘制圆形背景 ===

ctx.beginPath();

ctx.arc(cx, cy, TILE_SIZE / 2 - 8, 0, Math.PI * 2); // 半径 = 64/2 - 8 = 24px

ctx.fillStyle = unit.faction === Faction.PLAYER ? UNIT_COLOR_PLAYER : UNIT_COLOR_ENEMY;

ctx.fill();

ctx.strokeStyle = '#000000';

ctx.lineWidth = 2;

ctx.stroke();

// === 绘制兵种 Emoji ===

ctx.font = '20px Arial';

ctx.textAlign = 'center';

ctx.textBaseline = 'middle';

const symbols = '⚔️', '🏹', '🐎', '🔮', '👑';

ctx.fillText(symbolsunit.type, cx, cy - 5); // 略微上移给血条留空间

// === 绘制血条 ===

const barWidth = TILE_SIZE - 16; // 48px 宽

const barHeight = 4;

const barX = x + 8;

const barY = y + TILE_SIZE - 10; // 格子底部

// 血条背景(黑色)

ctx.fillStyle = '#000000';

ctx.fillRect(barX, barY, barWidth, barHeight);

// 当前血量(颜色随血量变化)

const hpPercent = unit.hp / unit.maxHp;

ctx.fillStyle = hpPercent > 0.5 ? '#00FF00' // 绿色:血量充足(>50%)

: hpPercent > 0.25 ? '#FFFF00' // 黄色:血量减少(25%-50%)

: '#FF0000'; // 红色:血量危险(<25%)

ctx.fillRect(barX, barY, barWidth * hpPercent, barHeight);

// 血条边框

ctx.strokeStyle = '#000000';

ctx.lineWidth = 1;

ctx.strokeRect(barX, barY, barWidth, barHeight);

}

```

血条颜色三段式设计

```

ctx.fillStyle = hpPercent > 0.5 ? '#00FF00'

: hpPercent > 0.25 ? '#FFFF00'

: '#FF0000';

```

这是三元运算符的嵌套使用,实现了三段式血条颜色:

```

HP > 50% → 绿色(安全)

HP 25-50% → 黄色(警戒)

HP < 25% → 红色(危险)

```

这个设计来自大多数游戏的 UI 惯例,玩家无需读数字就能快速判断单位状态。

Canvas arc 方法

```

ctx.arc(cx, cy, radius, startAngle, endAngle)

// cx, cy: 圆心坐标

// radius: 半径

// startAngle: 起始角度(弧度)

// endAngle: 结束角度(弧度)

// 完整圆: 0 到 Math.PI * 2

```

`ctx.beginPath()` 必须在绘制圆弧前调用,用于开始一条新路径,否则多个圆形会连接在一起。

六、选中高亮效果

```

// 金色描边框,内缩2px避免被网格线遮挡

ctx.strokeStyle = SELECTED_COLOR; // '#FFD700' 金色

ctx.lineWidth = 3;

ctx.strokeRect(

unit.position.x * TILE_SIZE + 2, // +2px 内缩

unit.position.y * TILE_SIZE + 2,

TILE_SIZE - 4, // -4px 补偿两侧各2px

TILE_SIZE - 4

);

```

内缩 2px 是细节处理:如果不内缩,3px 的描边会与相邻格子的网格线重叠,视觉上不够清晰。内缩后,金色选中框出现在格子内部,视觉分离明显。

七、半透明高亮技术

```

// 追加两位十六进制透明度到颜色字符串

ctx.fillStyle = MOVABLE_COLOR + '80'; // '80' ≈ 128/255 ≈ 50% 透明度

ctx.fillStyle = ATTACKABLE_COLOR + '80';

```

8 位 RGBA 十六进制颜色 `#RRGGBBAA`:

  • `'80'` (hex) = 128 (dec) ≈ 50% 不透明

  • `'FF'` = 100% 不透明

  • `'40'` = 25% 不透明

这种技巧简单直接,不需要使用 `rgba()` 函数,避免了解析颜色字符串的麻烦。

八、坐标系统

游戏使用了两套坐标系:

格子坐标(逻辑坐标)

  • 范围:x ∈ 0, 8,y ∈ 0, 7

  • 来源:地图数组下标、单位 position

  • 整数值

像素坐标(渲染坐标)

  • 范围:x ∈ 0, 576,y ∈ 0, 512

  • 来源:格子坐标 × TILE_SIZE

  • 用于 Canvas API 调用

转换公式:

```// 格子 → 像素(左上角)

const pixelX = tileX * TILE_SIZE;

const pixelY = tileY * TILE_SIZE;

// 像素 → 格子(触摸点)

const tileX = Math.floor(touchX / TILE_SIZE);

const tileY = Math.floor(touchY / TILE_SIZE);

```

九、渲染性能优化

本项目每次状态变化后调用 `drawGameMap()` 全量重绘,对于 9×8 小地图性能足够。

在大型游戏中,可以考虑**脏矩形优化**:

```

// 只重绘发生变化的格子

drawTile(x: number, y: number): void {

ctx.clearRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);

// 重绘该格子的地形、高亮、单位

}

```

十、小结

本游戏的 Canvas 渲染系统采用**分层绘制**策略,通过地形层→高亮层→单位层→选中层的绘制顺序,实现了清晰的视觉层次。血条的三段式颜色设计、选中框的内缩处理、半透明高亮技术,都是游戏 UI 开发中值得学习的实用技巧。