feat: 录像播放时显示玩家统计信息面板

在录像/观察者模式下,屏幕左上角显示半透明浮层,包含每位玩家的:
- 玩家名称(使用玩家颜色标识,带彩色下划线)
- 资源数量(金币 + 电力状态,低电力红色警告)
- 部队数量统计(建筑/步兵/载具/空军 + 总计)
- 建造队列(当前建造项目 + 进度条 + 暂停/就绪状态)
- 超级武器倒计时(充能进度条 + 就绪时闪烁警告)

设计要点:
- 半透明黑色背景,不遮挡游戏画面
- 多玩家自动分列显示,紧凑布局
- 仅在录像/观察者模式显示(localPlayer为空时)
- 5fps刷新率,低性能开销
- 跟随HUD侧边栏菜单同步显示/隐藏
This commit is contained in:
igofreely
2026-03-27 19:30:55 +08:00
parent df6e7eac11
commit bd4e3c8fa6
2 changed files with 410 additions and 1 deletions
+14 -1
View File
@@ -21,6 +21,7 @@ import { CommandBarButtonType } from "@/gui/screen/game/component/hud/commandBar
import { commandButtonConfigs } from "@/gui/screen/game/component/hud/commandBar/commandButtonConfigs";
import { isNotNullOrUndefined } from "@/util/typeGuard";
import { DebugText } from "@/gui/screen/game/component/hud/DebugText";
import { ReplayStatsOverlay } from "@/gui/screen/game/component/hud/ReplayStatsOverlay";
declare const THREE: any;
interface Viewport {
x: number;
@@ -87,6 +88,7 @@ export class Hud extends UiObject {
private messages?: any;
private debugText?: any;
private superWeaponTimers?: any;
private replayStatsOverlay?: any;
private menuContentContainer?: any;
private menuContentContainerInner?: any;
private menuContent?: any;
@@ -457,7 +459,16 @@ export class Hud extends UiObject {
x: sidebarBounds.x - 200,
y: actionBarY - 500,
ref: (ref: any) => (this.superWeaponTimers = ref),
}), jsx.jsx(GameMenuContentArea, {
}), !this.localPlayer ? jsx.jsx(ReplayStatsOverlay, {
players: this.players,
strings: this.strings,
width: Math.min(sidebarBounds.x - 10, 920),
height: 400,
x: 5,
y: 5,
zIndex: 5,
ref: (ref: any) => (this.replayStatsOverlay = ref),
}) : [], jsx.jsx(GameMenuContentArea, {
hidden: true,
screenSize: this.viewport,
viewport: {
@@ -498,6 +509,7 @@ export class Hud extends UiObject {
this.messages.getUiObject().setVisible(false);
this.debugText.getUiObject().setVisible(false);
this.superWeaponTimers.getUiObject().setVisible(false);
this.replayStatsOverlay?.getUiObject().setVisible(false);
}
hideSidebarMenu(): void {
this.sideCameoRepeaters.setVisible(true);
@@ -511,6 +523,7 @@ export class Hud extends UiObject {
this.messages.getUiObject().setVisible(true);
this.debugText.getUiObject().setVisible(true);
this.superWeaponTimers.getUiObject().setVisible(true);
this.replayStatsOverlay?.getUiObject().setVisible(true);
}
setMenuContentComponent(component: any): void {
const container = this.menuContentContainerInner;
@@ -0,0 +1,396 @@
import * as THREE from "three";
import { jsx } from "@/gui/jsx/jsx";
import { UiObject } from "@/gui/UiObject";
import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent";
import { HtmlContainer } from "@/gui/HtmlContainer";
import { SpriteUtils } from "@/engine/gfx/SpriteUtils";
import { QueueType, QueueStatus } from "@/game/player/production/ProductionQueue";
import { ObjectType } from "@/engine/type/ObjectType";
import { formatTimeDuration } from "@/util/format";
type Player = {
name: string;
credits: number;
defeated: boolean;
resigned: boolean;
color: {
asHexString: () => string;
};
powerTrait?: {
power: number;
drain: number;
};
superWeaponsTrait?: {
getAll: () => Array<{
name: string;
status: number; // SuperWeaponStatus
getTimerSeconds: () => number;
getChargeProgress: () => number;
rules: {
showTimer: boolean;
uiName: string;
rechargeTime: number;
};
}>;
};
production?: {
getAllQueues: () => Array<{
type: QueueType;
status: QueueStatus;
getFirst: () =>
| {
rules: { name: string };
quantity: number;
progress: number;
}
| undefined;
getAll: () => Array<{
rules: { name: string };
quantity: number;
progress: number;
}>;
}>;
};
getOwnedObjectsByType: (type: ObjectType, includeLimbo?: boolean) => any[];
getOwnedObjects: (includeLimbo?: boolean) => any[];
};
interface ReplayStatsOverlayProps extends UiComponentProps {
x?: number;
y?: number;
zIndex?: number;
width: number;
height: number;
players: Player[];
strings: {
get: (key: string) => string;
};
}
const FONT = "'Fira Sans Condensed', Arial, sans-serif";
const LINE_HEIGHT = 16;
const SECTION_GAP = 4;
const COL_WIDTH = 220;
const PADDING = 6;
const QUEUE_TYPE_LABELS: Record<number, string> = {
[QueueType.Structures]: "建筑",
[QueueType.Armory]: "防御",
[QueueType.Infantry]: "步兵",
[QueueType.Vehicles]: "载具",
[QueueType.Aircrafts]: "空军",
[QueueType.Ships]: "海军",
};
export class ReplayStatsOverlay extends UiComponent<ReplayStatsOverlayProps> {
declare ctx: CanvasRenderingContext2D;
declare texture: THREE.Texture;
declare mesh: THREE.Mesh;
lastUpdate?: number;
createUiObject() {
const obj = new UiObject(new THREE.Object3D(), new HtmlContainer());
obj.setPosition(this.props.x || 0, this.props.y || 0);
const { width, height } = this.props;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
this.ctx = canvas.getContext("2d", { alpha: true })!;
this.texture = this.createTexture(canvas);
this.mesh = this.createMesh(width, height);
return obj;
}
createTexture(canvas: HTMLCanvasElement) {
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
texture.flipY = false;
texture.minFilter = THREE.NearestFilter;
texture.magFilter = THREE.NearestFilter;
return texture;
}
createMesh(width: number, height: number) {
const geometry = SpriteUtils.createRectGeometry(width, height);
SpriteUtils.addRectUvs(
geometry,
{ x: 0, y: 0, width, height },
{ width, height },
);
geometry.translate(width / 2, height / 2, 0);
const material = new THREE.MeshBasicMaterial({
map: this.texture,
side: THREE.DoubleSide,
transparent: true,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.frustumCulled = false;
return mesh;
}
defineChildren() {
return jsx("mesh", { zIndex: this.props.zIndex }, this.mesh);
}
onFrame(now: number) {
// Update at ~5 fps to avoid perf impact
if (!this.lastUpdate || now - this.lastUpdate >= 200) {
this.lastUpdate = now;
this.render();
}
}
private render() {
const ctx = this.ctx;
const { width, height, players } = this.props;
ctx.clearRect(0, 0, width, height);
const activePlayers = players.filter(
(p) => !p.defeated && !p.resigned,
);
if (activePlayers.length === 0) return;
// Layout: place player panels in columns
const numCols = Math.min(activePlayers.length, 4);
const colW = Math.min(COL_WIDTH, Math.floor((width - PADDING * 2) / numCols));
for (let i = 0; i < activePlayers.length; i++) {
const player = activePlayers[i];
const col = i % numCols;
const row = Math.floor(i / numCols);
const x = PADDING + col * (colW + SECTION_GAP);
const baseY = PADDING + row * 200; // rough estimate per player block
this.renderPlayer(ctx, player, x, baseY, colW);
}
this.texture.needsUpdate = true;
}
private renderPlayer(
ctx: CanvasRenderingContext2D,
player: Player,
x: number,
startY: number,
colWidth: number,
) {
let y = startY;
// ── Player name header with colored underline ──
const color = player.color.asHexString();
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
ctx.fillRect(x, y, colWidth, LINE_HEIGHT + 2);
ctx.fillStyle = color;
ctx.font = `bold 12px ${FONT}`;
ctx.textBaseline = "top";
ctx.fillText(player.name, x + 4, y + 2);
// Thin colored underline
ctx.fillStyle = color;
ctx.fillRect(x, y + LINE_HEIGHT, colWidth, 2);
y += LINE_HEIGHT + 2 + SECTION_GAP;
// ── Credits & Power ──
const credits = Math.floor(player.credits);
const powerTotal = player.powerTrait?.power ?? 0;
const powerDrain = player.powerTrait?.drain ?? 0;
const powerColor =
powerDrain > powerTotal ? "#ff4444" : "#88ff88";
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(x, y, colWidth, LINE_HEIGHT);
ctx.font = `11px ${FONT}`;
ctx.fillStyle = "#ffd700";
ctx.fillText(`$${credits}`, x + 4, y + 2);
ctx.fillStyle = powerColor;
const powerText = `⚡${powerTotal}/${powerDrain}`;
ctx.fillText(powerText, x + colWidth / 2, y + 2);
y += LINE_HEIGHT + 1;
// ── Unit Counts ──
const buildings = player.getOwnedObjectsByType(
ObjectType.Building,
).length;
const infantry = player.getOwnedObjectsByType(
ObjectType.Infantry,
).length;
const vehicles = player.getOwnedObjectsByType(
ObjectType.Vehicle,
).length;
const aircraft = player.getOwnedObjectsByType(
ObjectType.Aircraft,
).length;
const totalUnits = buildings + infantry + vehicles + aircraft;
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(x, y, colWidth, LINE_HEIGHT);
ctx.font = `11px ${FONT}`;
ctx.fillStyle = "#cccccc";
const countsText = `🏠${buildings} 🚶${infantry} 🚛${vehicles}`;
ctx.fillText(countsText, x + 4, y + 2);
if (aircraft > 0) {
ctx.fillText(`✈${aircraft}`, x + colWidth - 40, y + 2);
}
// Total on right side
ctx.fillStyle = "#aaaaaa";
ctx.textAlign = "right";
ctx.fillText(`Σ${totalUnits}`, x + colWidth - 4, y + 2);
ctx.textAlign = "left";
y += LINE_HEIGHT + 1;
// ── Production Queues ──
if (player.production) {
const queues = player.production.getAllQueues();
for (const queue of queues) {
const first = queue.getFirst();
if (
queue.status === QueueStatus.Idle ||
!first
)
continue;
const label =
QUEUE_TYPE_LABELS[queue.type] || "?";
const itemName = first.rules.name;
const progress = Math.floor(first.progress * 100);
const statusStr =
queue.status === QueueStatus.OnHold
? " ⏸"
: queue.status === QueueStatus.Ready
? " ✓"
: "";
ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
ctx.fillRect(x, y, colWidth, LINE_HEIGHT);
// Label
ctx.fillStyle = "#999999";
ctx.font = `10px ${FONT}`;
ctx.fillText(label, x + 4, y + 3);
// Item name + progress
ctx.fillStyle = "#dddddd";
ctx.font = `11px ${FONT}`;
ctx.fillText(`${itemName}`, x + 36, y + 2);
// Progress bar
if (
queue.status === QueueStatus.Active &&
first.progress > 0
) {
const barX = x + colWidth - 54;
const barW = 40;
const barH = 8;
const barY = y + 4;
ctx.fillStyle = "rgba(255, 255, 255, 0.15)";
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
ctx.fillRect(
barX,
barY,
barW * first.progress,
barH,
);
ctx.globalAlpha = 1;
ctx.fillStyle = "#ffffff";
ctx.font = `9px ${FONT}`;
ctx.textAlign = "center";
ctx.fillText(
`${progress}%`,
barX + barW / 2,
barY,
);
ctx.textAlign = "left";
} else {
ctx.fillStyle = "#aaaaaa";
ctx.textAlign = "right";
ctx.font = `10px ${FONT}`;
ctx.fillText(statusStr, x + colWidth - 4, y + 3);
ctx.textAlign = "left";
}
// Multiple items indicator
const allItems = queue.getAll();
if (allItems.length > 1 || first.quantity > 1) {
const totalQ = allItems.reduce(
(sum, item) => sum + item.quantity,
0,
);
if (totalQ > 1) {
ctx.fillStyle = "#aaaaaa";
ctx.font = `9px ${FONT}`;
ctx.fillText(
`×${totalQ}`,
x + 80,
y + 3,
);
}
}
y += LINE_HEIGHT;
}
}
// ── Superweapon Countdowns ──
if (player.superWeaponsTrait) {
const superWeapons = player.superWeaponsTrait.getAll();
for (const sw of superWeapons) {
if (!sw.rules.showTimer) continue;
const seconds = Math.floor(sw.getTimerSeconds());
const label = this.props.strings.get(sw.rules.uiName);
const isReady = seconds <= 0;
const progress = sw.getChargeProgress();
ctx.fillStyle = "rgba(0, 0, 0, 0.45)";
ctx.fillRect(x, y, colWidth, LINE_HEIGHT);
ctx.font = `11px ${FONT}`;
if (isReady) {
// Ready - flash
const flash =
Math.floor(Date.now() / 500) % 2 === 0;
ctx.fillStyle = flash ? "#ff4444" : "#ffaa00";
ctx.fillText(`☢ ${label} READY`, x + 4, y + 2);
} else {
ctx.fillStyle = "#ff8800";
ctx.fillText(`☢ ${label}`, x + 4, y + 2);
// Timer
ctx.fillStyle = "#ffcc66";
ctx.textAlign = "right";
ctx.fillText(
formatTimeDuration(seconds, false),
x + colWidth - 48,
y + 2,
);
ctx.textAlign = "left";
// Small progress bar
const barX = x + colWidth - 44;
const barW = 40;
const barH = 6;
const barY = y + 5;
ctx.fillStyle = "rgba(255, 255, 255, 0.12)";
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = "#ff6600";
ctx.globalAlpha = 0.8;
ctx.fillRect(
barX,
barY,
barW * progress,
barH,
);
ctx.globalAlpha = 1;
}
y += LINE_HEIGHT;
}
}
}
onDispose() {
this.mesh.geometry.dispose();
(this.mesh.material as THREE.Material).dispose();
this.texture.dispose();
}
}