diff --git a/apollo/actions/applyPower.tsx b/apollo/actions/applyPower.tsx index 8e318394..6e7ca68e 100644 --- a/apollo/actions/applyPower.tsx +++ b/apollo/actions/applyPower.tsx @@ -17,12 +17,17 @@ import getAirUnitsToRecover from '@deities/athena/lib/getAirUnitsToRecover.tsx'; import matchesActiveType from '@deities/athena/lib/matchesActiveType.tsx'; import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; import updatePlayers from '@deities/athena/lib/updatePlayers.tsx'; -import { Charge, HealAmount } from '@deities/athena/map/Configuration.tsx'; +import { + Charge, + HealAmount, + MinSize, +} from '@deities/athena/map/Configuration.tsx'; import Player from '@deities/athena/map/Player.tsx'; import Unit, { UnitConversion } from '@deities/athena/map/Unit.tsx'; import Vector from '@deities/athena/map/Vector.tsx'; -import MapData from '@deities/athena/MapData.tsx'; +import MapData, { SizeVector } from '@deities/athena/MapData.tsx'; import { VisionT } from '@deities/athena/Vision.tsx'; +import resizeMap from '../lib/resizeMap.tsx'; const conversions = new Map>([ [Skill.SpawnUnitInfernoJetpack, { from: Flamethrower, to: InfernoJetpack }], @@ -133,6 +138,24 @@ export default function applyPower(skill: Skill, map: MapData) { const healTypes = getHealUnitTypes(skill); let player = map.getCurrentPlayer(); + if (skill === Skill.HighTide) { + map = resizeMap( + resizeMap( + map, + new SizeVector( + Math.max(MinSize, map.size.width - 1), + Math.max(MinSize, map.size.height - 1), + ), + new Set(['left', 'top']), + ), + new SizeVector( + Math.max(MinSize, map.size.width - 2), + Math.max(MinSize, map.size.height - 2), + ), + new Set(), + ); + } + if (skill === Skill.Charge) { player = player.setCharge(player.charge + ChargeSkillCharges * Charge); map = map.copy({ diff --git a/apollo/lib/applyConditions.tsx b/apollo/lib/applyConditions.tsx index be528082..86818f29 100644 --- a/apollo/lib/applyConditions.tsx +++ b/apollo/lib/applyConditions.tsx @@ -1,10 +1,13 @@ -import MapData from '@deities/athena/MapData.tsx'; +import { Skill } from '@deities/athena/info/Skill.tsx'; +import { MinSize } from '@deities/athena/map/Configuration.tsx'; +import MapData, { SizeVector } from '@deities/athena/MapData.tsx'; import { ActionResponse } from '../ActionResponse.tsx'; import applyActionResponse from '../actions/applyActionResponse.tsx'; import { applyEffects, Effects } from '../Effects.tsx'; import { applyObjectives } from '../Objective.tsx'; import { GameState, GameStateWithEffects } from '../Types.tsx'; import getLosingPlayer from './getLosingPlayer.tsx'; +import resizeEffects from './resizeEffects.tsx'; export default function applyConditions( currentMap: MapData, @@ -22,7 +25,31 @@ export default function applyConditions( while (queue.length) { const previousMap = currentMap; - const [actionResponse, currentEffects, _addToGameState] = queue.shift()!; + const [actionResponse, _currentEffects, _addToGameState] = queue.shift()!; + let currentEffects = _currentEffects; + if ( + actionResponse.type === 'ActivatePower' && + actionResponse.skill === Skill.HighTide + ) { + effects = currentEffects = resizeEffects( + resizeEffects( + effects, + currentMap.size, + new SizeVector( + Math.max(MinSize, currentMap.size.width - 1), + Math.max(MinSize, currentMap.size.height - 1), + ), + new Set(['left', 'top']), + ), + currentMap.size, + new SizeVector( + Math.max(MinSize, currentMap.size.width - 2), + Math.max(MinSize, currentMap.size.height - 2), + ), + new Set(), + ); + } + const activeMap = applyActionResponse( previousMap, previousMap.createVisionObject(previousMap.currentPlayer), diff --git a/apollo/lib/resizeEffects.tsx b/apollo/lib/resizeEffects.tsx new file mode 100644 index 00000000..402370e5 --- /dev/null +++ b/apollo/lib/resizeEffects.tsx @@ -0,0 +1,31 @@ +import { SizeVector } from '@deities/athena/MapData.tsx'; +import { Effects } from '../Effects.tsx'; +import { resizeEntities, ResizeOrigin } from './resizeMap.tsx'; + +export default function resizeEffects( + effects: Effects, + previousSize: SizeVector, + size: SizeVector, + origin: Set, +) { + const offsetX = origin.has('left') ? previousSize.width - size.width : 0; + const offsetY = origin.has('top') ? previousSize.height - size.height : 0; + return new Map( + [...effects].map(([trigger, effectList]) => [ + trigger, + new Set( + [...effectList].map((effect) => ({ + ...effect, + actions: [...effect.actions].map((action) => + action.type === 'SpawnEffect' + ? ({ + ...action, + units: resizeEntities(action.units, size, offsetX, offsetY), + } as const) + : action, + ), + })), + ), + ]), + ); +} diff --git a/apollo/lib/resizeMap.tsx b/apollo/lib/resizeMap.tsx index 67eef5cb..ee207b04 100644 --- a/apollo/lib/resizeMap.tsx +++ b/apollo/lib/resizeMap.tsx @@ -13,11 +13,10 @@ import Vector from '@deities/athena/map/Vector.tsx'; import MapData, { SizeVector } from '@deities/athena/MapData.tsx'; import { objectiveHasVectors } from '@deities/athena/Objectives.tsx'; import ImmutableMap from '@nkzw/immutable-map'; -import { Effects } from '../Effects.tsx'; export type ResizeOrigin = 'top' | 'right' | 'bottom' | 'left'; -const updateEntities = ( +export const resizeEntities = ( entities: ImmutableMap, size: SizeVector, offsetX: number, @@ -27,12 +26,11 @@ const updateEntities = ( .mapKeys((vector) => new SpriteVector(vector.x, vector.y).left(offsetX).up(offsetY), ) - .filter((_: unknown, vector: Vector): boolean => size.contains(vector)) + .filter((_: unknown, vector: Vector) => size.contains(vector)) .mapKeys((vector) => vec(vector.x, vector.y)); export default function resizeMap( map: MapData, - effects: Effects, size: SizeVector, origin: Set, fill?: number, @@ -63,14 +61,7 @@ export default function resizeMap( Math.floor((subVector.y - 1) / DecoratorsPerSide) + 1, ), ) - ? [ - ...decorators, - [subVector.x, subVector.y, decorator.id] as [ - number, - number, - number, - ], - ] + ? [...decorators, [subVector.x, subVector.y, decorator.id] as const] : decorators; }, [] as PlainEntitiesList, @@ -92,36 +83,16 @@ export default function resizeMap( : objective, ); - return [ - verifyMap( - withModifiers( - map.copy({ - buildings: updateEntities(map.buildings, size, offsetX, offsetY), - config: map.config.copy({ objectives }), - decorators: decodeDecorators(size, decorators), - map: tiles, - size, - units: updateEntities(map.units, size, offsetX, offsetY), - }), - ), + return verifyMap( + withModifiers( + map.copy({ + buildings: resizeEntities(map.buildings, size, offsetX, offsetY), + config: map.config.copy({ objectives }), + decorators: decodeDecorators(size, decorators), + map: tiles, + size, + units: resizeEntities(map.units, size, offsetX, offsetY), + }), ), - new Map( - [...effects].map(([trigger, effectList]) => [ - trigger, - new Set( - [...effectList].map((effect) => ({ - ...effect, - actions: [...effect.actions].map((action) => - action.type === 'SpawnEffect' - ? ({ - ...action, - units: updateEntities(action.units, size, offsetX, offsetY), - } as const) - : action, - ), - })), - ), - ]), - ), - ] as const; + ); } diff --git a/athena/info/Skill.tsx b/athena/info/Skill.tsx index 3b213d4a..f59dc992 100644 --- a/athena/info/Skill.tsx +++ b/athena/info/Skill.tsx @@ -54,6 +54,7 @@ export enum Skill { Shield = 38, Charge = 39, DragonSaboteur = 40, + HighTide = 41, } export const Skills = new Set([ @@ -92,6 +93,7 @@ export const Skills = new Set([ Skill.RecoverAirUnits, Skill.Shield, Skill.Charge, + Skill.HighTide, Skill.SpawnUnitInfernoJetpack, Skill.AttackAndDefenseDecreaseEasy, Skill.AttackAndDefenseIncreaseHard, @@ -282,6 +284,13 @@ const skillConfig: Record< cost: 1000, group: SkillGroup.Special, }, + [Skill.HighTide]: { + activateOnInvasion, + charges: 1, + cost: 200, + group: SkillGroup.Invasion, + requiresCrystal, + }, }; export const CampaignOnlySkills = new Set( @@ -1363,6 +1372,7 @@ export function shouldUpgradeUnit(unit: Unit, skill: Skill) { case Skill.UnitRailDefenseIncreasePowerAttackIncrease: case Skill.UnlockPowerStation: case Skill.VampireHeal: + case Skill.HighTide: return false; default: { skill satisfies never; diff --git a/dionysus/lib/shouldActivatePower.tsx b/dionysus/lib/shouldActivatePower.tsx index bed2a7a0..ab778ade 100644 --- a/dionysus/lib/shouldActivatePower.tsx +++ b/dionysus/lib/shouldActivatePower.tsx @@ -31,6 +31,7 @@ const shouldConsiderUnitRatio = (skill: Skill) => { case Skill.BuyUnitBear: case Skill.BuyUnitOctopus: case Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor: + case Skill.HighTide: case Skill.Shield: return false; diff --git a/hera/behavior/activatePower/activatePowerAction.tsx b/hera/behavior/activatePower/activatePowerAction.tsx index d9e2f5d0..d95a046d 100644 --- a/hera/behavior/activatePower/activatePowerAction.tsx +++ b/hera/behavior/activatePower/activatePowerAction.tsx @@ -14,8 +14,13 @@ import { import { HealEntry } from '@deities/athena/lib/getUnitsToHeal.tsx'; import matchesActiveType from '@deities/athena/lib/matchesActiveType.tsx'; import updatePlayer from '@deities/athena/lib/updatePlayer.tsx'; -import { HealAmount, MaxHealth } from '@deities/athena/map/Configuration.tsx'; +import { + FastAnimationConfig, + HealAmount, + MaxHealth, +} from '@deities/athena/map/Configuration.tsx'; import { PlayerID } from '@deities/athena/map/Player.tsx'; +import vec from '@deities/athena/map/vec.tsx'; import Vector, { sortByVectorKey, sortVectors, @@ -27,6 +32,7 @@ import animateHeal from '../../lib/animateHeal.tsx'; import AnimationKey from '../../lib/AnimationKey.tsx'; import damageUnits from '../../lib/damageUnits.tsx'; import getSkillConfigForDisplay from '../../lib/getSkillConfigForDisplay.tsx'; +import sleep from '../../lib/sleep.tsx'; import spawn from '../../lib/spawn.tsx'; import upgradeUnits from '../../lib/upgradeUnits.tsx'; import { Actions, State, StateLike } from '../../Types.tsx'; @@ -56,7 +62,7 @@ export default async function activatePowerAction( state: State, actionResponse: ActivatePowerActionResponse, ): Promise { - const { requestFrame, update } = actions; + const { requestFrame, scheduleTimer, scrollIntoView, update } = actions; const { skill, units: unitsToSpawn } = actionResponse; const { colors, name } = getSkillConfigForDisplay(skill); const player = state.map.getCurrentPlayer(); @@ -175,9 +181,28 @@ export default async function activatePowerAction( return fn(state); } - requestFrame(() => - resolve({ ...state, map: finalMap, ...resetBehavior() }), - ); + requestFrame(async () => { + if (skill === Skill.HighTide) { + await scrollIntoView([ + vec( + Math.floor(state.map.size.width / 2), + Math.floor(state.map.size.height / 2), + ), + ]); + await update({ + animations: state.animations.set(new AnimationKey(), { + type: 'shake', + }), + }); + await sleep(scheduleTimer, FastAnimationConfig, 'long'); + await update({ + animations: state.animations.set(new AnimationKey(), { + type: 'shake', + }), + }); + } + resolve({ ...state, map: finalMap, ...resetBehavior() }); + }); return null; }; diff --git a/hera/editor/MapEditor.tsx b/hera/editor/MapEditor.tsx index 846fd941..38fd1ddb 100644 --- a/hera/editor/MapEditor.tsx +++ b/hera/editor/MapEditor.tsx @@ -6,6 +6,7 @@ import { encodeEffects, Scenario, } from '@deities/apollo/Effects.tsx'; +import resizeEffects from '@deities/apollo/lib/resizeEffects.tsx'; import resizeMap, { ResizeOrigin } from '@deities/apollo/lib/resizeMap.tsx'; import { Route } from '@deities/apollo/Routes.tsx'; import getCampaignRoute from '@deities/apollo/routes/getCampaignRoute.tsx'; @@ -791,13 +792,13 @@ export default function MapEditor({ (size: SizeVector, origin: Set) => { const map = stateRef.current?.map; if (map && !size.equals(map.size)) { - const [newMap, newEffects] = resizeMap( - map, + const newEffects = resizeEffects( editor.effects, + map.size, size, origin, - editor?.selected?.tile, ); + const newMap = resizeMap(map, size, origin, editor?.selected?.tile); setMap('resize', newMap); setEditorState({ action: undefined, diff --git a/hera/lib/getSkillBasedPortrait.tsx b/hera/lib/getSkillBasedPortrait.tsx index 3fa5cc02..68752e57 100644 --- a/hera/lib/getSkillBasedPortrait.tsx +++ b/hera/lib/getSkillBasedPortrait.tsx @@ -72,16 +72,17 @@ export default function getSkillBasedPortrait(skill: Skill): UnitInfo | null { case Skill.HealInfantryMedicPower: case Skill.VampireHeal: return Medic; - case Skill.Charge: case Skill.ArtilleryRangeIncrease: case Skill.AttackAndDefenseDecreaseEasy: case Skill.AttackAndDefenseIncreaseHard: case Skill.AttackIncreaseMajorDefenseDecreaseMajor: case Skill.AttackIncreaseMinor: + case Skill.Charge: case Skill.CounterAttackPower: case Skill.DecreaseUnitCostAttackAndDefenseDecreaseMinor: case Skill.DefenseIncreaseMinor: case Skill.HealVehiclesAttackDecrease: + case Skill.HighTide: case Skill.MovementIncreaseGroundUnitDefenseDecrease: case Skill.NoUnitRestrictions: case Skill.RecoverAirUnits: diff --git a/hera/lib/getSkillConfigForDisplay.tsx b/hera/lib/getSkillConfigForDisplay.tsx index e04fa53b..1ef5ae17 100644 --- a/hera/lib/getSkillConfigForDisplay.tsx +++ b/hera/lib/getSkillConfigForDisplay.tsx @@ -15,6 +15,7 @@ import Paw from '@deities/ui/icons/Paw.tsx'; import Poison from '@deities/ui/icons/Poison.tsx'; import { SkillIconBorderStyle } from '@deities/ui/icons/SkillBorder.tsx'; import Skull from '@deities/ui/icons/Skull.tsx'; +import Tide from '@deities/ui/icons/Tide.tsx'; import Track from '@deities/ui/icons/Track.tsx'; import Tree from '@deities/ui/icons/Tree.tsx'; import Zombie from '@deities/ui/icons/Zombie.tsx'; @@ -361,10 +362,18 @@ export default function getSkillConfigForDisplay(skill: Skill): SkillConfig { return { alpha: 0.1, borderStyle: 'up2x', - colors: 'purple', + colors: ['purple', 'blue'], icon: Glasses, name: fbt('Sneaky Dragon', 'Skill name'), }; + case Skill.HighTide: + return { + alpha: 0.3, + borderStyle: 'down', + colors: ['blue', 'purple'], + icon: Tide, + name: fbt('High Tide', 'Skill name'), + }; default: { skill satisfies never; throw new UnknownTypeError('getSkillConfig', skill); diff --git a/hera/ui/SkillDescription.tsx b/hera/ui/SkillDescription.tsx index bd0a6800..509b519c 100644 --- a/hera/ui/SkillDescription.tsx +++ b/hera/ui/SkillDescription.tsx @@ -918,6 +918,14 @@ const getExtraPowerDescription = (skill: Skill, color: BaseColor) => { unit. ); + case Skill.HighTide: + return ( + + Raises the water level, causing the battlefield to shrink inward by + one tile on each side. Eliminates any units or structures located at + the edges. + + ); } return null; diff --git a/tests/__tests__/Unit.test.tsx b/tests/__tests__/Unit.test.tsx index 762ed811..2b2b1cb5 100644 --- a/tests/__tests__/Unit.test.tsx +++ b/tests/__tests__/Unit.test.tsx @@ -112,9 +112,8 @@ test('displays all units and all possible states correctly', async () => { const finalMap = map.copy({ map: newMap }); - const [mapA] = resizeMap( + const mapA = resizeMap( finalMap, - new Map(), new SizeVector(size.width, Math.ceil(size.height / 2)), new Set(['bottom']), ); @@ -125,9 +124,8 @@ test('displays all units and all possible states correctly', async () => { printGameState('Units Part 1', screenshotA); expect(screenshotA).toMatchImageSnapshot(); - const [mapB] = resizeMap( + const mapB = resizeMap( finalMap, - new Map(), new SizeVector(size.width, Math.floor(size.height / 2)), new Set(['top']), ); diff --git a/ui/icons/Tide.tsx b/ui/icons/Tide.tsx new file mode 100644 index 00000000..e5de8ed9 --- /dev/null +++ b/ui/icons/Tide.tsx @@ -0,0 +1,5 @@ +export default { + body: ``, + height: 24, + width: 24, +};