2026-08-15 18:42:06 +03:00

444 lines
24 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Button,
Container,
Divider,
Grid2,
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Tooltip,
Typography,
Theme,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import { useTheme } from '@mui/material/styles';
import { useParams } from 'next/navigation';
import { ArrowBack, ExpandMore } from '@mui/icons-material';
import AvTimerOutlinedIcon from '@mui/icons-material/AvTimer';
import { AvailableBuildings, AvailableMods, AvailableUnits, IconUrl } from '@/src/core/api';
import { IShortWeapon, IUnit } from '@/src/types/IUnit';
import { IRaceUnits } from '@/src/types/IUnitShort';
import { IRaceBuildings } from '@/src/types/IBuildingShort';
import '@/src/css/Unit.css';
import ArmorType from '@/src/classes/ArmorType';
import Sergeant from '@/src/classes/Sergeant';
import WeaponSlot from '@/src/classes/WeaponSlot';
import UnitsTable from '@/src/classes/UnitsTable';
import { IMod } from '@/src/types/Imod';
import Vision from '@/src/classes/Vision';
import Required from '@/src/classes/Required';
import { AffectedResearches } from '@/src/classes/building/Research';
import { ModifiersProvidesTable } from '@/src/classes/ModifiersProvideTable';
import Ability from '@/src/classes/Ability';
import DeathExplosion from '@/src/classes/DeathExplosion';
import Jump from '@/src/classes/Jump';
import { DescriptionBox } from '@/src/commons/DescriptionBox';
import { BackButton } from '@/src/commons/BackButton';
const SectionTitle = styled(Typography)(({ theme }) => ({
fontWeight: 700,
mb: 2,
fontSize: '1.25rem',
}));
const StatsPaper = styled(Paper)(({ theme }) => ({
background: theme.palette.mode === 'dark'
? 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: '12px',
overflow: 'hidden',
'& .MuiTableBody .MuiTableRow-root': {
'&:last-child td, &:last-child th': { border: 0 },
},
'& .MuiTableBody .MuiTableRow-root .MuiTableCell-head': {
color: theme.palette.mode === 'dark' ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)',
fontWeight: 600,
},
}));
const SergeantAccordion = styled(Accordion)(({ theme }) => ({
background: theme.palette.mode === 'dark'
? 'linear-gradient(145deg, #1a1a2e 0%, #16213e 100%)'
: 'linear-gradient(145deg, #ffffff 0%, #f5f5f5 100%)',
border: `1px solid ${theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}`,
borderRadius: '12px',
mb: 1,
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: '0 0 8px 0' },
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
my: 3,
borderColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
}));
const UnitTitle = styled(Typography)(({ theme }) => ({
fontWeight: 800,
mb: 1,
display: 'flex',
alignItems: 'center',
gap: 2,
}));
const UnitSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
fontWeight: 500,
}));
function Unit(unit: IUnit, mod: IMod, theme: Theme, racesUnits: IRaceUnits[], racesBuildings: IRaceBuildings[]) {
const isDark = theme.palette.mode === 'dark';
const morale = (unit.moraleMax !== null) ? <span>
<img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" />&nbsp;{unit.moraleMax}
+{unit.moraleRegeneration}/s
{unit.moraleDeathPenalty > 0 && <span> <img style={{ verticalAlign: "top" }}
src="/images/Kills_icon.webp" /> -{unit.moraleDeathPenalty}</span>}
</span> : "-"
let mapWithUnitWeapons: Map<number, Map<number, IShortWeapon>> = new Map();
unit.weapons.forEach(weapon => {
const weaponMap = mapWithUnitWeapons.get(weapon.hardpoint)
if (weaponMap == null) {
const weaponMap = new Map()
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
mapWithUnitWeapons.set(weapon.hardpoint, weaponMap)
} else {
weaponMap.set(weapon.hardpointOrder, weapon.weapon)
}
})
type sergeantProps = {
name: string
icon: String
canDetect: Boolean
}
const SergeantShort = (props: sergeantProps) => {
return (
<span style={{ fontSize: 20, color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', display: 'flex', alignItems: 'center' }}>
{props.icon && <img className="sergeantIcon" src={IconUrl + props.icon.replaceAll('\\', '/')} />}
&nbsp; {props.name}
{props.canDetect && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/DETECT_YES.webp" /></span>}
</span>
)
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<UnitTitle variant="h4">
{unit.icon &&
<img className="unitIcon" src={IconUrl + unit.icon.replaceAll('\\', '/')} />}
{unit.name}
</UnitTitle>
<UnitSubtitle variant="subtitle1">
{mod.name} ({mod.version})
</UnitSubtitle>
</Box>
<Grid2 container spacing={3}>
<Grid2 size={{ xs: 12, md: 4 }}>
<StatsPaper>
<Table size="small" aria-label="a dense table">
<TableBody id="unit-stats-table">
<TableRow>
<TableCell component="th" scope="row" >Cost</TableCell>
<TableCell>
{unit.buildCostRequisition > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.buildCostRequisition.toFixed(0)}</span>}
{unit.buildCostPower > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.buildCostPower.toFixed(0)}</span>}
{(unit.buildCostPopulation !== undefined && unit.buildCostPopulation > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_orksquadcap.gif" />&nbsp;
{unit.buildCostPopulation.toFixed(0)}</span>}
{(unit.buildCostFaith !== undefined && unit.buildCostFaith > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.buildCostFaith}</span>}
{(unit.buildCostSouls !== undefined && unit.buildCostSouls > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_souls.gif" />&nbsp;
{unit.buildCostSouls.toFixed(0)}</span>}
{unit.capInfantry > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_cap_infantry.gif" />&nbsp;
{unit.capInfantry}</span>}
{unit.capSupport > 0 && <span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_cap_vehicle.gif" />&nbsp;
{unit.capSupport}</span>}
{(unit.buildCostTime !== undefined && unit.buildCostTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{ verticalAlign: "top", fontSize: "18px" }} />&nbsp;
{unit.buildCostTime}s</span>}
</TableCell>
</TableRow>
{(unit?.reinforceTime !== 0 && unit.reinforceTime !== null && unit.squadMaxSize > 1) &&
<TableRow>
<TableCell>Reinforce cost</TableCell>
<TableCell>
{unit.reinforceCostRequisition && unit.reinforceCostRequisition > 0 ?
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.reinforceCostRequisition.toFixed(0)}</span> : <span />}
{unit.reinforceCostPower !== undefined && unit.reinforceCostPower > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.reinforceCostPower.toFixed(0)}</span>}
{(unit.reinforceCostPopulation !== undefined && unit.reinforceCostPopulation > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_orksquadcap.gif" />&nbsp;
{unit.reinforceCostPopulation.toFixed(0)}</span>}
{(unit.reinforceCostFaith !== undefined && unit.reinforceCostFaith > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.reinforceCostFaith}</span>}
{(unit.reinforceCostSouls !== undefined && unit.reinforceCostSouls > 0) &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_souls.gif" />&nbsp;
{unit.reinforceCostSouls.toFixed(0)}</span>}
{(unit.reinforceTime !== undefined && unit.reinforceTime > 0) &&
<span>&nbsp;<AvTimerOutlinedIcon
style={{ verticalAlign: "top", fontSize: "18px" }} />&nbsp;
{unit.reinforceTime}s</span>}
</TableCell>
</TableRow>
}
{(unit.requisitionIncome !== undefined && unit.requisitionIncome !== null || unit.powerIncome !== undefined && unit.powerIncome !== null || unit.faithIncome !== undefined && unit.faithIncome !== null) &&
<TableRow>
<TableCell >Resource income</TableCell>
<TableCell>
{unit.requisitionIncome !== undefined && unit.requisitionIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_requisition.gif" />&nbsp;
{unit.requisitionIncome}</span>}
{unit.powerIncome !== undefined && unit.powerIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_power.gif" />&nbsp;
{unit.powerIncome}</span>}
{unit.faithIncome !== undefined && unit.faithIncome > 0 &&
<span>&nbsp;<img style={{ verticalAlign: "top" }}
src="/images/Resource_faith.gif" />&nbsp;
{unit.faithIncome}</span>}
</TableCell>
</TableRow>
}
{unit.squadMaxSize > 1 &&
<TableRow>
<TableCell >Squad size</TableCell>
<TableCell>{unit.squadStartSize} / {unit.squadMaxSize}</TableCell>
</TableRow>
}
<TableRow>
<TableCell >Armor type</TableCell>
<TableCell>
{unit.armorType2 == null ? <ArmorType name={unit.armorType.name} compact={false} /> : <Tooltip title={"upgrade/debuff can turns to " + unit.armorType2.name}><span><ArmorType name={unit.armorType.name} compact={false} /></span></Tooltip>}
</TableCell>
</TableRow>
<TableRow>
<TableCell >Health</TableCell>
<TableCell>
<img style={{ verticalAlign: "top" }}
src="/images/Health_icon.webp" />&nbsp;
{unit.health} {unit.healthRegeneration > 0 &&
<span>+{unit.healthRegeneration}/s</span>}
{unit.armour !== undefined && unit.armour !== 0 && <span><img style={{ height: 20, verticalAlign: "top" }} src="/images/defence.png" />{unit.armour} </span>}
</TableCell>
</TableRow>
<TableRow>
<TableCell >Move speed</TableCell>
<TableCell>{unit.moveSpeed}</TableCell>
</TableRow>
<TableRow>
<TableCell >Morale</TableCell>
<TableCell>{morale}</TableCell>
</TableRow>
<TableRow>
<TableCell >Mass</TableCell>
<TableCell>{unit.mass}</TableCell>
</TableRow>
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Vision</TableCell>
<TableCell><Vision sight={unit.sightRadius} detect={unit.detectRadius} /></TableCell>
</TableRow>
{unit.repairMax !== undefined && unit.repairMax !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Repair max</TableCell>
<TableCell>{unit.repairMax}</TableCell>
</TableRow>
}
{unit.repairSpeed !== undefined && unit.repairSpeed !== null && unit.repairCostPercent !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Repair</TableCell>
<TableCell>{unit.repairSpeed} hp/s; {unit.repairCostPercent}% cost</TableCell>
</TableRow>
}
{unit.mobValue != null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Mob value</TableCell>
<TableCell><span> <img style={{ height: 20, verticalAlign: "top" }} src="/images/Mob_bonus.gif" /> {unit.mobValue} </span></TableCell>
</TableRow>
}
{unit.squadLimit !== undefined && unit.squadLimit !== null &&
<TableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>
<TableCell >Limit</TableCell>
<TableCell>{unit.squadLimit}</TableCell>
</TableRow>
}
</TableBody>
</Table>
</StatsPaper>
</Grid2>
<Grid2 size={{ xs: 12, md: 8 }}>
<DescriptionBox>
{unit.description}
</DescriptionBox>
</Grid2>
{unit.requirements !== null &&
<Grid2 size={{ xs: 12, md: 12 }}>
<Required requirement={unit.requirements} modId={unit.modId} raceId={unit.race.id} />
</Grid2>}
<Grid2 size={12}>
{unit.sergeants.map(s =>
<SergeantAccordion key={s.id} TransitionProps={{ unmountOnExit: true, timeout: 100 }}>
<AccordionSummary
sx={{ color: isDark ? '#dee2e6' : 'rgba(0, 0, 0, 0.85)', '& .MuiAccordionSummary-content': { margin: '12px 0' }, '& .MuiAccordionSummary-content.Mui-expanded': { margin: '12px 0' } }}>
<SergeantShort name={s.name} icon={s.icon} canDetect={s.detectRadius > 0} />
</AccordionSummary>
<AccordionDetails sx={{ color: theme.palette.text.primary }}>
<Sergeant mod={mod} sergeant={s} race={unit.race} />
</AccordionDetails>
</SergeantAccordion>)}
</Grid2>
{unit.moraleMax !== null && !(unit.moraleBrakeModifiers.find(m => m.reference.includes("accuracy_weapon_modifier") && m.value === 0.2) !== undefined &&
unit.moraleBrakeModifiers.find(m => m.reference.includes("speed_maximum_modifier") && m.value === 1.2) !== undefined &&
unit.moraleBrakeModifiers.length === 2)
&& <Grid2 size={12}>
<SectionTitle><img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" /> Morale broken <img style={{ verticalAlign: "top" }} src="/images/ARM_Morale.webp" /></SectionTitle>
<ModifiersProvidesTable modifiers={unit.moraleBrakeModifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData} />
</Grid2>}
{unit.modifiers.length > 0 && <Grid2 size={12}>
<SectionTitle>Affected on</SectionTitle>
<ModifiersProvidesTable modifiers={unit.modifiers} modId={unit.modId} race={unit.race} affectedData={unit.affectedData} />
</Grid2>}
{unit.abilities.length > 0 && <Grid2 size={12}>
<SectionTitle>Abilities</SectionTitle>
{unit.abilities.map(a =>
<Ability mod={mod} ability={a} race={unit.race} />
)}
</Grid2>}
{unit.jumps != null && <Grid2 size={12}>
<Jump jumps={unit.jumps} race={unit.race} mod={mod} />
</Grid2>}
<Grid2 size={12}>
{[...mapWithUnitWeapons.keys()].sort(function (a, b) {
return a - b;
}).map(h => <WeaponSlot haveReinforceMenu={unit.haveReinforceMenu} key={h} race={unit.race} mod={mod} unitWeapons={mapWithUnitWeapons.get(h)} hardpoint={h} />)}
</Grid2>
{unit.deathExplosions.length > 0 &&
<Grid2 size={12}>
{unit.deathExplosions.map(da =>
<DeathExplosion deathExplosion={da} mod={mod} />
)}
</Grid2>
}
<Grid2 size={12}>
<AffectedResearches researches={unit.affectedResearches} modId={mod.id} raceId={unit.race.id} />
</Grid2>
</Grid2>
<Box sx={{ mt: 3, mb: 2, fontWeight: 600, fontSize: '0.85rem' }}>
Hotkey: {unit.hotkey} <span style={{ fontSize: '12px', fontWeight: 400, color: 'rgba(128,128,128,0.7)' }}>Filename: {unit.filename}</span>
</Box>
<StyledDivider />
<UnitsTable modId={mod.id} racesUnits={racesUnits} racesBuildings={racesBuildings} />
</Box>
)
}
export default function UnitPageClient({
initialUnit,
initialMod,
}: {
initialUnit: IUnit | null;
initialMod: IMod | null;
}) {
const params = useParams() as Record<string, string>;
const { modId, raceId, unitId } = params;
const theme = useTheme();
const [unit, setUnit] = useState<IUnit | null>(initialUnit);
const [mod, setMod] = useState<IMod | null>(initialMod);
const [racesUnits, setRacesUnits] = useState<IRaceUnits[]>([]);
const [racesBuildings, setRacesBuildings] = useState<IRaceBuildings[]>([]);
useEffect(() => {
let cancelled = false;
if (unit == null) {
fetch(AvailableUnits + "/" + unitId)
.then(res => res.json())
.then((res: IUnit) => { if (!cancelled) setUnit(res); });
}
if (mod == null) {
fetch(AvailableMods + "/" + modId)
.then(res => res.json())
.then((res: IMod) => { if (!cancelled) setMod(res); });
}
fetch(AvailableUnits + "/mod/" + modId)
.then(res => res.json())
.then((resUnits: IRaceUnits[]) => {
if (cancelled) return;
setRacesUnits(resUnits);
if (resUnits.length <= 10) {
fetch(AvailableBuildings + "/mod/" + modId)
.then(res => res.json())
.then((resBuildings: IRaceBuildings[]) => {
if (!cancelled) setRacesBuildings(resBuildings);
});
}
});
return () => { cancelled = true; };
}, [modId, raceId, unitId]);
if (unit != null && mod != null) {
const backRef = "/mod/" + modId + "/race/" + raceId;
return (
<Container maxWidth="lg">
<BackButton
variant="outlined"
startIcon={<ArrowBack />}
href={backRef}
>
Back to race
</BackButton>
{Unit(unit, mod, theme, racesUnits, racesBuildings)}
</Container>
);
} else {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<LinearProgress sx={{ width: '200px' }} />
</Box>
);
}
}