5c41eb410b
- 新增HUD图标、面板和状态指示器资产 - 实现HUD图标组件和错误边界组件 - 重构顶部状态栏和底部导航栏 - 更新路线面板样式和交互 - 添加HUD资产清单和切片脚本 - 移除未使用的资产文件 - 调整API安全配置和生产环境设置
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import type { EdgeView, GameView, InventoryViewEntry, QuestView } from '@tinywaste/game-core';
|
|
import { getEquipmentArt, getItemArt, getStatusArt } from './uiAssets';
|
|
|
|
export interface EquipmentPreviewEntry {
|
|
id: string;
|
|
label: string;
|
|
title: string;
|
|
subtitle: string;
|
|
art?: string;
|
|
isEquipped: boolean;
|
|
}
|
|
|
|
/** Readiness weights for each survival stat (must sum to 100) */
|
|
const READINESS_WEIGHTS = {
|
|
life: 34,
|
|
energy: 28,
|
|
thirst: 20,
|
|
hunger: 18,
|
|
} as const;
|
|
|
|
/** Readiness thresholds for tone classification */
|
|
const READINESS_TONE_THRESHOLD_GOOD = 74;
|
|
const READINESS_TONE_THRESHOLD_WARN = 48;
|
|
|
|
/** Risk tier boundaries */
|
|
const RISK_LOW_MAX = 0.35;
|
|
const RISK_MID_MAX = 0.65;
|
|
|
|
/** Survivor metric formula constants */
|
|
const PROTECTION_BASE_ARMOR = 34;
|
|
const PROTECTION_BASE_UNARMORED = 12;
|
|
const PROTECTION_STR_FACTOR = 5;
|
|
const PROTECTION_LIFE_FACTOR = 30;
|
|
|
|
const STEALTH_AGI_FACTOR = 8;
|
|
const STEALTH_PER_FACTOR = 5;
|
|
const STEALTH_SANITY_FACTOR = 20;
|
|
|
|
const MOBILITY_AGI_FACTOR = 7;
|
|
const MOBILITY_ENERGY_FACTOR = 42;
|
|
const MOBILITY_LOAD_PENALTY = 18;
|
|
|
|
const PLACE_ATMOSPHERE: Record<
|
|
string,
|
|
{
|
|
weather: string;
|
|
temperature: string;
|
|
wind: string;
|
|
}
|
|
> = {
|
|
home: { weather: 'Light rain', temperature: '18°C', wind: 'Wind 12 km/h' },
|
|
roadside: { weather: 'Cloud break', temperature: '16°C', wind: 'Wind 18 km/h' },
|
|
old_store: { weather: 'Dust drift', temperature: '20°C', wind: 'Wind 9 km/h' },
|
|
village_market: { weather: 'Dry overcast', temperature: '22°C', wind: 'Wind 7 km/h' },
|
|
rain_farm: { weather: 'Wet fog', temperature: '14°C', wind: 'Wind 10 km/h' },
|
|
city_edge: { weather: 'Soot haze', temperature: '19°C', wind: 'Wind 15 km/h' },
|
|
subway_entrance: { weather: 'Cold draft', temperature: '11°C', wind: 'Wind 6 km/h' },
|
|
sewer: { weather: 'Toxic mist', temperature: '13°C', wind: 'Air still' },
|
|
vault_gate: { weather: 'Ash fall', temperature: '9°C', wind: 'Wind 4 km/h' },
|
|
};
|
|
|
|
export function edgeDestination(view: GameView, edge: EdgeView) {
|
|
return view.map.places.find((place) => place.id === edge.to);
|
|
}
|
|
|
|
export function getTimeLabels(timeLabel: string) {
|
|
const timeSegments = timeLabel.split('·').map((part) => part.trim());
|
|
return {
|
|
dayLabel: timeSegments[0] ?? 'Day 1',
|
|
clockLabel: timeSegments[1] ?? timeLabel,
|
|
};
|
|
}
|
|
|
|
export function getActiveQuestCount(view: GameView) {
|
|
return view.quests.filter((quest) => quest.state === 'active').length;
|
|
}
|
|
|
|
export function getAlertCount(view: GameView) {
|
|
return Number(Boolean(view.pendingEvent)) + Number(Boolean(view.combat));
|
|
}
|
|
|
|
export function getDominantAlert(view: GameView) {
|
|
return view.survival.alerts[0] ?? null;
|
|
}
|
|
|
|
export function getCurrentPlace(view: GameView) {
|
|
return view.map.places.find((place) => place.current) ?? null;
|
|
}
|
|
|
|
export function getPlaceAtmosphere(placeId: string) {
|
|
return PLACE_ATMOSPHERE[placeId] ?? { weather: 'Dry static', temperature: '17°C', wind: 'Wind 10 km/h' };
|
|
}
|
|
|
|
export function getLatestLog(view: GameView) {
|
|
return view.logs[0] ?? null;
|
|
}
|
|
|
|
export function getVisibleRoutes(view: GameView, limit = 3) {
|
|
return view.map.edges.slice(0, limit);
|
|
}
|
|
|
|
export function getQuickUseItems(view: GameView, limit = 6) {
|
|
const priority = {
|
|
water: 0,
|
|
food: 1,
|
|
medicine: 2,
|
|
tool: 3,
|
|
material: 4,
|
|
weapon: 5,
|
|
armor: 6,
|
|
ammo: 7,
|
|
quest: 8,
|
|
} as const;
|
|
|
|
return [...view.player.inventory]
|
|
.filter((item) => item.canUse)
|
|
.sort((left, right) => {
|
|
const priorityDelta = priority[left.type] - priority[right.type];
|
|
if (priorityDelta !== 0) return priorityDelta;
|
|
return right.count - left.count;
|
|
})
|
|
.slice(0, limit);
|
|
}
|
|
|
|
export function getObjectiveRows(view: GameView) {
|
|
return view.quests
|
|
.filter((quest) => quest.state === 'active' || quest.state === 'completed')
|
|
.map((quest) => {
|
|
const doneCount = quest.steps.filter((step) => step.done).length;
|
|
const nextStep = quest.steps.find((step) => !step.done)?.text ?? quest.steps.at(-1)?.text ?? '待机';
|
|
|
|
return {
|
|
id: quest.id,
|
|
title: quest.title,
|
|
detail: nextStep,
|
|
progress: `${doneCount}/${quest.steps.length}`,
|
|
done: quest.state === 'completed',
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getPrimaryObjective(view: GameView) {
|
|
return getObjectiveRows(view)[0] ?? null;
|
|
}
|
|
|
|
export function getAttributeRows(view: GameView) {
|
|
const { attributes } = view.player;
|
|
|
|
return [
|
|
{ label: 'STR', name: '力量', value: attributes.str },
|
|
{ label: 'AGI', name: '敏捷', value: attributes.agi },
|
|
{ label: 'INT', name: '智力', value: attributes.int },
|
|
{ label: 'PER', name: '感知', value: attributes.per },
|
|
{ label: 'LCK', name: '幸运', value: attributes.luck },
|
|
];
|
|
}
|
|
|
|
export function getStatusEffects(view: GameView) {
|
|
const effects = view.survival.alerts.map((entry) => ({
|
|
id: entry.stat,
|
|
label: `${entry.label} · ${entry.summary}`,
|
|
detail: entry.impact,
|
|
recovery: entry.recovery,
|
|
art: getStatusArt(entry.stat),
|
|
tone:
|
|
entry.tier === 'strained' ? ('warn' as const) : entry.tier === 'safe' ? ('good' as const) : ('danger' as const),
|
|
}));
|
|
|
|
if (!effects.length) {
|
|
return [
|
|
{
|
|
id: 'stable',
|
|
label: '稳定',
|
|
detail: '当前幸存者状态平稳,可以继续规划下一步行动。',
|
|
recovery: '保持补给循环与节奏即可。',
|
|
art: getStatusArt('life'),
|
|
tone: 'good' as const,
|
|
},
|
|
];
|
|
}
|
|
|
|
return effects.slice(0, 4);
|
|
}
|
|
|
|
export function getExpeditionStatus(view: GameView) {
|
|
const { stats, maxStats } = view.player;
|
|
const readiness = Math.round(
|
|
(stats.life / maxStats.life) * READINESS_WEIGHTS.life +
|
|
(stats.energy / maxStats.energy) * READINESS_WEIGHTS.energy +
|
|
(stats.thirst / maxStats.thirst) * READINESS_WEIGHTS.thirst +
|
|
(stats.hunger / maxStats.hunger) * READINESS_WEIGHTS.hunger,
|
|
);
|
|
const loadPercent = Math.round((view.player.inventoryUsage / view.player.inventoryCapacity) * 100);
|
|
const threatCount = view.survival.alerts.length;
|
|
|
|
return {
|
|
readiness,
|
|
loadPercent,
|
|
threatCount,
|
|
tone: readiness >= READINESS_TONE_THRESHOLD_GOOD && threatCount === 0 ? 'good' : readiness >= READINESS_TONE_THRESHOLD_WARN ? 'warn' : 'danger',
|
|
};
|
|
}
|
|
|
|
export function getSurvivorMetrics(view: GameView) {
|
|
const protection = Math.min(
|
|
100,
|
|
Math.round(
|
|
(view.player.equipment.body ? PROTECTION_BASE_ARMOR : PROTECTION_BASE_UNARMORED) +
|
|
view.player.attributes.str * PROTECTION_STR_FACTOR +
|
|
(view.player.stats.life / view.player.maxStats.life) * PROTECTION_LIFE_FACTOR,
|
|
),
|
|
);
|
|
const stealth = Math.min(
|
|
100,
|
|
Math.round(
|
|
view.player.attributes.agi * STEALTH_AGI_FACTOR +
|
|
view.player.attributes.per * STEALTH_PER_FACTOR +
|
|
(view.player.stats.sanity / view.player.maxStats.sanity) * STEALTH_SANITY_FACTOR,
|
|
),
|
|
);
|
|
const mobility = Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
Math.round(
|
|
view.player.attributes.agi * MOBILITY_AGI_FACTOR +
|
|
(view.player.stats.energy / view.player.maxStats.energy) * MOBILITY_ENERGY_FACTOR -
|
|
(view.player.inventoryUsage / view.player.inventoryCapacity) * MOBILITY_LOAD_PENALTY,
|
|
),
|
|
),
|
|
);
|
|
|
|
return [
|
|
{ id: 'protection', label: '防护', value: protection },
|
|
{ id: 'stealth', label: '潜行', value: stealth },
|
|
{ id: 'mobility', label: '机动', value: mobility },
|
|
];
|
|
}
|
|
|
|
export function getSupplyCounts(view: GameView) {
|
|
const counts = {
|
|
water: 0,
|
|
food: 0,
|
|
medicine: 0,
|
|
material: 0,
|
|
};
|
|
|
|
for (const item of view.player.inventory) {
|
|
if (item.type === 'water') counts.water += item.count;
|
|
if (item.type === 'food') counts.food += item.count;
|
|
if (item.type === 'medicine') counts.medicine += item.count;
|
|
if (item.type === 'material') counts.material += item.count;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
export function getKitItem(view: GameView) {
|
|
return view.player.inventory.find((item) => item.type === 'medicine' || item.type === 'water') ?? null;
|
|
}
|
|
|
|
export function getLoadoutEntries(view: GameView) {
|
|
const inventoryById = new Map(view.player.inventory.map((item) => [item.itemId, item]));
|
|
const weaponItem = view.player.equipment.weapon ? inventoryById.get(view.player.equipment.weapon) : undefined;
|
|
const bodyItem = view.player.equipment.body ? inventoryById.get(view.player.equipment.body) : undefined;
|
|
const toolItem = view.player.equipment.tool ? inventoryById.get(view.player.equipment.tool) : undefined;
|
|
const kitItem = getKitItem(view);
|
|
|
|
const left: EquipmentPreviewEntry[] = [
|
|
{
|
|
id: 'head',
|
|
label: 'Head',
|
|
title: '遮面兜帽',
|
|
subtitle: '造型预置位',
|
|
art: getEquipmentArt(undefined, 'head'),
|
|
isEquipped: false,
|
|
},
|
|
{
|
|
id: 'body',
|
|
label: 'Body',
|
|
title: bodyItem?.name ?? '未装备护甲',
|
|
subtitle: bodyItem ? '已挂载在躯干槽' : '建议保留一件抗风装备',
|
|
art: getEquipmentArt(bodyItem?.itemId, 'body'),
|
|
isEquipped: Boolean(bodyItem),
|
|
},
|
|
{
|
|
id: 'back',
|
|
label: 'Back',
|
|
title: '远行背包',
|
|
subtitle: `容量 ${view.player.inventoryUsage}/${view.player.inventoryCapacity}`,
|
|
art: getEquipmentArt(undefined, 'back'),
|
|
isEquipped: true,
|
|
},
|
|
];
|
|
|
|
const right: EquipmentPreviewEntry[] = [
|
|
{
|
|
id: 'weapon',
|
|
label: 'Weapon',
|
|
title: weaponItem?.name ?? '未装备主武器',
|
|
subtitle: weaponItem ? '武器槽在线' : '可通过蓝图制作远程装备',
|
|
art: getEquipmentArt(weaponItem?.itemId, 'weapon'),
|
|
isEquipped: Boolean(weaponItem),
|
|
},
|
|
{
|
|
id: 'tool',
|
|
label: 'Tool',
|
|
title: toolItem?.name ?? '未挂载工具',
|
|
subtitle: toolItem ? '工具槽在线' : '撬棍可作为早期万能工具',
|
|
art: getEquipmentArt(toolItem?.itemId, 'tool'),
|
|
isEquipped: Boolean(toolItem),
|
|
},
|
|
{
|
|
id: 'kit',
|
|
label: 'Kit',
|
|
title: kitItem?.name ?? '未配置急救包',
|
|
subtitle: kitItem ? `携带 ${kitItem.count} 件应急补给` : '建议随身携带水或药物',
|
|
art: kitItem ? getItemArt(kitItem.itemId, kitItem.type) : getEquipmentArt(undefined, 'kit'),
|
|
isEquipped: Boolean(kitItem),
|
|
},
|
|
];
|
|
|
|
return { left, right };
|
|
}
|
|
|
|
export function getRouteRiskTier(risk: number) {
|
|
if (risk <= RISK_LOW_MAX) return { label: '低风险', tone: 'safe' as const };
|
|
if (risk <= RISK_MID_MAX) return { label: '中风险', tone: 'warn' as const };
|
|
return { label: '高风险', tone: 'danger' as const };
|
|
}
|
|
|
|
export function getRiskDots(risk: number) {
|
|
const lit = Math.max(1, Math.min(6, Math.round(risk * 6)));
|
|
return Array.from({ length: 6 }, (_, index) => index < lit);
|
|
}
|
|
|
|
export function getInventoryStateLabel(item: InventoryViewEntry) {
|
|
if (item.equipped) return '已装备';
|
|
if (item.canUse) return '可立即使用';
|
|
if (item.equipSlot) return '可挂载';
|
|
return '物资储备';
|
|
}
|
|
|
|
export function getQuestProgress(quest: QuestView) {
|
|
return `${quest.steps.filter((step) => step.done).length}/${quest.steps.length}`;
|
|
}
|