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}`; }