From 27dabe6586aa79ed5a21be4e59e99ce159a1f3d9 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 01:06:29 +0200 Subject: [PATCH 1/4] refactor: improve game state management and UI components - Moved the rendering of the field in FieldState to ensure it is always called when entering or resuming the state. - Added methods in GameSceneState to check for character navigation requests using keyboard shortcuts. - Updated margin calculations in ItemMenuState, MainMenuState, and ShopState to ensure they do not go negative. - Enhanced StatusViewState to allow cycling through party members using keyboard shortcuts and improved margin calculations. - Modified GameOverScene to recenter layout on screen resize and adjusted header positioning. - Implemented a returnFromBattleScene method in SceneManager to handle transitions back to the game scene. - Improved TitleScene to recenter layout on screen resize. - Adjusted LocationHUDWindow to be inactive by default and added a refreshLayout method for repositioning. - Refined Modal rendering to account for text width correctly, including handling ANSI sequences. - Added unit tests for AttackAction, BattleGroup, BattlePacing, Camera, ColoredCamera, and TerminalText functionalities. --- src/Battle/Actions/AttackAction.php | 16 +- src/Battle/BattlePacing.php | 93 +++++ src/Battle/BattleResult.php | 28 ++ src/Battle/BattleRewards.php | 14 +- src/Battle/BattleTurnTimings.php | 58 ++++ .../States/ActionExecutionState.php | 222 +++++++++++- .../Traditional/States/EnemyActionState.php | 28 +- .../Traditional/States/PlayerActionState.php | 146 +++++++- .../Traditional/States/TurnInitState.php | 40 ++- .../States/TurnResolutionState.php | 61 +++- .../States/TurnStateExecutionContext.php | 124 ++++++- src/Battle/Engines/TurnBasedEngines/Turn.php | 4 +- .../TurnBasedEngines/TurnBasedEngine.php | 16 +- .../Enumerations/BattleActionCategory.php | 73 ++++ src/Battle/Enumerations/BattlePace.php | 62 ++++ src/Battle/PartyBattlerPositions.php | 8 +- src/Battle/UI/BattleCharacterStatusWindow.php | 8 +- src/Battle/UI/BattleCommandContextWindow.php | 15 +- src/Battle/UI/BattleCommandWindow.php | 21 +- src/Battle/UI/BattleFieldWindow.php | 109 +++++- src/Battle/UI/BattleResultWindow.php | 56 +++ src/Battle/UI/BattleScreen.php | 111 +++++- src/Core/Game.php | 86 ++++- .../EquipmentMenuCommandSelectionMode.php | 3 +- .../Modes/EquipmentSelectionMode.php | 158 ++++++++- .../Windows/CharacterDetailPanel.php | 79 +++-- .../Windows/EquipmentAssignmentPanel.php | 7 +- .../Windows/EquipmentCommandPanel.php | 5 +- .../ItemMenu/Windows/ItemSelectionPanel.php | 7 +- src/Core/Menu/Menu.php | 11 +- .../Modes/PurchaseConfirmationMode.php | 10 +- .../Menu/ShopMenu/Windows/ShopMainPanel.php | 7 +- src/Entities/Actions/SleepAction.php | 6 +- src/Entities/BattleGroup.php | 5 +- src/Entities/Character.php | 61 ++-- src/Entities/Party.php | 41 ++- src/Field/MapManager.php | 87 +++-- src/Field/Player.php | 29 +- src/IO/Console/Console.php | 112 ++++-- src/IO/Console/Cursor.php | 14 +- src/IO/Console/TerminalText.php | 327 ++++++++++++++++++ src/IO/Enumerations/KeyCode.php | 1 + src/IO/InputManager.php | 3 +- src/Rendering/Camera.php | 146 ++++++-- src/Scenes/AbstractScene.php | 14 +- src/Scenes/Battle/BattleScene.php | 19 +- .../Battle/States/BattleDefeatState.php | 26 +- src/Scenes/Battle/States/BattleEndState.php | 26 +- src/Scenes/Battle/States/BattleStartState.php | 4 +- .../Battle/States/BattleVictoryState.php | 26 +- src/Scenes/Game/GameScene.php | 37 +- src/Scenes/Game/States/EquipmentMenuState.php | 60 +++- src/Scenes/Game/States/FieldState.php | 5 +- src/Scenes/Game/States/GameSceneState.php | 24 +- src/Scenes/Game/States/ItemMenuState.php | 4 +- src/Scenes/Game/States/MainMenuState.php | 4 +- src/Scenes/Game/States/ShopState.php | 4 +- src/Scenes/Game/States/StatusViewState.php | 27 +- src/Scenes/GameOver/GameOverScene.php | 27 +- src/Scenes/SceneManager.php | 18 +- src/Scenes/Title/TitleScene.php | 28 +- src/UI/Elements/LocationHUDWindow.php | 16 +- src/UI/Modal/Modal.php | 16 +- src/UI/Modal/SelectModal.php | 15 +- src/UI/Windows/CommandPanel.php | 5 +- src/UI/Windows/Window.php | 65 ++-- src/Util/Helpers.php | 14 +- tests/Pest.php | 75 +++- tests/Unit/AttackActionTest.php | 17 + tests/Unit/BattleGroupTest.php | 48 +++ tests/Unit/BattlePacingTest.php | 18 + tests/Unit/CameraTest.php | 47 +++ tests/Unit/ColoredCameraTest.php | 19 + tests/Unit/TerminalTextTest.php | 40 +++ 74 files changed, 2874 insertions(+), 392 deletions(-) create mode 100644 src/Battle/BattlePacing.php create mode 100644 src/Battle/BattleResult.php create mode 100644 src/Battle/BattleTurnTimings.php create mode 100644 src/Battle/Enumerations/BattleActionCategory.php create mode 100644 src/Battle/Enumerations/BattlePace.php create mode 100644 src/Battle/UI/BattleResultWindow.php create mode 100644 src/IO/Console/TerminalText.php create mode 100644 tests/Unit/AttackActionTest.php create mode 100644 tests/Unit/BattleGroupTest.php create mode 100644 tests/Unit/BattlePacingTest.php create mode 100644 tests/Unit/CameraTest.php create mode 100644 tests/Unit/ColoredCameraTest.php create mode 100644 tests/Unit/TerminalTextTest.php diff --git a/src/Battle/Actions/AttackAction.php b/src/Battle/Actions/AttackAction.php index 77262c2..54eb452 100644 --- a/src/Battle/Actions/AttackAction.php +++ b/src/Battle/Actions/AttackAction.php @@ -3,6 +3,7 @@ namespace Ichiloto\Engine\Battle\Actions; use Ichiloto\Engine\Battle\BattleAction; +use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Interfaces\CharacterInterface as Actor; class AttackAction extends BattleAction @@ -12,9 +13,20 @@ class AttackAction extends BattleAction */ public function execute(Actor $actor, array $targets): void { - // TODO: Implement execute() method. + if ($actor->isKnockedOut) { + return; + } + + $attack = $actor instanceof Character ? $actor->effectiveStats->attack : $actor->stats->attack; + foreach ($targets as $target) { + if (! $target instanceof Actor || $target->isKnockedOut) { + continue; + } + $defence = $target instanceof Character ? $target->effectiveStats->defence : $target->stats->defence; + $damage = max(1, $attack - intval($defence / 2)); + $target->stats->currentHp -= $damage; } } -} \ No newline at end of file +} diff --git a/src/Battle/BattlePacing.php b/src/Battle/BattlePacing.php new file mode 100644 index 0000000..7fc498a --- /dev/null +++ b/src/Battle/BattlePacing.php @@ -0,0 +1,93 @@ +messageDurationOverride ?? $this->messagePace->messageDurationSeconds(), + 0.1, + 10.0 + ); + } + + /** + * Returns the staged timings for the provided action. + * + * @param BattleAction|null $action The action being resolved. + * @return BattleTurnTimings + */ + public function getTurnTimings(?BattleAction $action): BattleTurnTimings + { + $category = BattleActionCategory::fromAction($action); + + return BattleTurnTimings::fromTotalDuration( + $category->totalDurationSeconds($this->animationPace) + ); + } +} diff --git a/src/Battle/BattleResult.php b/src/Battle/BattleResult.php new file mode 100644 index 0000000..c2c2790 --- /dev/null +++ b/src/Battle/BattleResult.php @@ -0,0 +1,28 @@ +items)) { + return null; + } + foreach ($this->items as $item) { if (mt_rand() / mt_getrandmax() <= $item->dropRate) { - return $item; + return $item->item; } } @@ -75,4 +79,4 @@ public function __construct( } } } -} \ No newline at end of file +} diff --git a/src/Battle/BattleTurnTimings.php b/src/Battle/BattleTurnTimings.php new file mode 100644 index 0000000..ab36f93 --- /dev/null +++ b/src/Battle/BattleTurnTimings.php @@ -0,0 +1,58 @@ +stepForward + + $this->announcement + + $this->actionAnimation + + $this->effectAnimation + + $this->stepBack + + $this->statChanges + + $this->turnOver; + } +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php index 3096639..69a26e1 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php @@ -2,27 +2,231 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; +use Ichiloto\Engine\Battle\BattleAction; +use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\TurnExecutionContext; +use Ichiloto\Engine\Entities\Character; +use Ichiloto\Engine\Entities\Enemies\Enemy; +use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; + class ActionExecutionState extends TurnState { + /** + * @inheritDoc + */ + public function enter(TurnStateExecutionContext $context): void + { + $context->resetTurnCursor(); + $context->ui->commandWindow->blur(); + $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->commandContextWindow->clear(); + $context->ui->hideMessage(); + $context->ui->refresh(); + } /** * @inheritDoc */ public function update(TurnStateExecutionContext $context): void { - // TODO: Implement execute() method. - // For each battler + $turn = $context->getCurrentTurn(); + + if ($turn === null) { + $this->setState($this->engine->turnResolutionState); + return; + } - // Step forward + if ($turn->battler->isKnockedOut) { + $context->advanceTurn(); + return; + } - // Announce action + $targets = array_values(array_filter( + $turn->targets, + fn(CharacterInterface $target) => ! $target->isKnockedOut + )); - // Apply effects with animations + if (empty($targets)) { + $targets = $context->getLivingOpponents($turn->battler); + } - // Trigger reactions + if (empty($targets)) { + $context->advanceTurn(); + return; + } - // Update ui + $target = $targets[0]; + $turn->targets = [$target]; - // Step back + $actionName = $turn->action?->name ?? 'Attack'; + $this->performTurnSequence( + $context, + $turn->battler, + $target, + $turn->action, + $actionName, + function () use ($turn) { + $turn->execute(new TurnExecutionContext( + $this->engine, + $this->engine->battleConfig, + )); + } + ); + + $context->advanceTurn(); + + if ($context->getCurrentTurn() === null) { + $this->setState($this->engine->turnResolutionState); + } + } + + /** + * Performs the staged turn sequence for the acting battler. + * + * @param TurnStateExecutionContext $context The turn context. + * @param CharacterInterface $actor The acting battler. + * @param CharacterInterface $target The action target. + * @param BattleAction|null $action The action being resolved. + * @param string $actionName The action name. + * @param callable $resolveAction The action resolution callback. + * @return void + */ + protected function performTurnSequence( + TurnStateExecutionContext $context, + CharacterInterface $actor, + CharacterInterface $target, + ?BattleAction $action, + string $actionName, + callable $resolveAction + ): void + { + $timings = $context->ui->getPacing()->getTurnTimings($action); + + $this->highlightActor($context, $actor); + $this->stepActorForward($context, $actor); + $this->pause($timings->stepForward); + $this->displayPhase($context, sprintf('%s uses %s!', $actor->name, $actionName), $timings->announcement); + $this->pause($timings->actionAnimation); + $this->displayPhase($context, '*SFX*', $timings->effectAnimation); + + $previousHp = $target->stats->currentHp; + $resolveAction(); + + $this->stepActorBack($context, $actor); + $this->pause($timings->stepBack); + + $context->ui->characterStatusWindow->setCharacters($context->party->battlers->toArray()); + $context->ui->refresh(); + + $damage = max(0, $previousHp - $target->stats->currentHp); + $summary = $damage > 0 + ? sprintf('%s took %d damage.', $target->name, $damage) + : sprintf('%s was unaffected.', $target->name); + + if ($target->isKnockedOut) { + $summary .= sprintf(' %s was defeated.', $target->name); + } + + $this->displayPhase($context, $summary, $timings->statChanges); + $this->displayPhase($context, 'Turn over.', $timings->turnOver, hideAfter: true); + $context->ui->characterNameWindow->activeIndex = -1; + } + + /** + * Highlights the acting battler in the party name window when applicable. + * + * @param TurnStateExecutionContext $context The turn context. + * @param CharacterInterface $actor The acting battler. + * @return void + */ + protected function highlightActor(TurnStateExecutionContext $context, CharacterInterface $actor): void + { + $partyBattlers = $context->party->battlers->toArray(); + $actorIndex = array_search($actor, $partyBattlers, true); + $context->ui->characterNameWindow->activeIndex = is_int($actorIndex) ? $actorIndex : -1; + } + + /** + * Steps the acting battler forward. + * + * @param TurnStateExecutionContext $context The turn context. + * @param CharacterInterface $actor The acting battler. + * @return void + */ + protected function stepActorForward(TurnStateExecutionContext $context, CharacterInterface $actor): void + { + if ($actor instanceof Character) { + $partyBattlers = $context->party->battlers->toArray(); + $actorIndex = array_search($actor, $partyBattlers, true); + + if (is_int($actorIndex)) { + $context->ui->fieldWindow->stepPartyBattlerForward($actor, $actorIndex); + } + + return; + } + + if ($actor instanceof Enemy) { + $context->ui->fieldWindow->stepTroopBattlerForward($actor); + } + } + + /** + * Returns the acting battler to its idle position. + * + * @param TurnStateExecutionContext $context The turn context. + * @param CharacterInterface $actor The acting battler. + * @return void + */ + protected function stepActorBack(TurnStateExecutionContext $context, CharacterInterface $actor): void + { + if ($actor instanceof Character) { + $partyBattlers = $context->party->battlers->toArray(); + $actorIndex = array_search($actor, $partyBattlers, true); + + if (is_int($actorIndex)) { + $context->ui->fieldWindow->stepPartyBattlerBack($actor, $actorIndex); + } + + return; + } + + if ($actor instanceof Enemy) { + $context->ui->fieldWindow->stepTroopBattlerBack($actor); + } + } + + /** + * Shows a message in the info panel and waits for the specified duration. + * + * @param TurnStateExecutionContext $context The turn context. + * @param string $message The message to display. + * @param float $delaySeconds The time to wait in seconds. + * @param bool $hideAfter Whether to hide the info panel afterwards. + * @return void + */ + protected function displayPhase( + TurnStateExecutionContext $context, + string $message, + float $delaySeconds, + bool $hideAfter = false + ): void + { + $context->ui->showMessage($message); + $this->pause($delaySeconds); + + if ($hideAfter) { + $context->ui->hideMessage(); + } + } + + /** + * Waits for the given number of seconds. + * + * @param float $seconds The time to wait in seconds. + * @return void + */ + protected function pause(float $seconds): void + { + usleep(max(0, intval(round($seconds * 1000000)))); } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/EnemyActionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/EnemyActionState.php index 06d9873..b7798b3 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/EnemyActionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/EnemyActionState.php @@ -2,21 +2,35 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; -use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnState; +use Ichiloto\Engine\Battle\Actions\AttackAction; class EnemyActionState extends TurnState { - /** * @inheritDoc */ public function update(TurnStateExecutionContext $context): void { - // TODO: Implement execute() method. - // For each enemy, + $targets = $context->getLivingPartyBattlers(); + + if (empty($targets)) { + $this->setState($this->engine->turnResolutionState); + return; + } + + foreach ($context->getLivingTroopBattlers() as $enemy) { + $turn = $context->findTurnForBattler($enemy); + + if ($turn === null) { + continue; + } + + $turn->action = new AttackAction('Attack'); + $turn->targets = [$targets[array_rand($targets)]]; + } - // Select action + $context->ui->commandContextWindow->clear(); - // Select target + $this->setState($this->engine->actionExecutionState); } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php index fed626f..f584f41 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php @@ -40,8 +40,14 @@ public function enter(TurnStateExecutionContext $context): void { $context->ui->setState($context->ui->playerActionState); $this->menuStack = new Stack(MenuInterface::class); - $this->activeCharacterIndex = 0; - $this->loadCharacterActions(); + $this->activeCharacterIndex = -1; + + if (empty($context->getLivingPartyBattlers())) { + $this->setState($this->engine->enemyActionState); + return; + } + + $this->selectNextCharacter($context, true); } /** @@ -72,30 +78,24 @@ protected function handleNavigation(TurnStateExecutionContext $context): void } } - /** - * Selects the target. - * - * @param TurnStateExecutionContext $context The context. - */ protected function selectTarget(TurnStateExecutionContext $context): void { + // Reserved for command-specific sub-selection UIs such as spells, summons, and items. } /** - * Confirms the action. + * Confirms or rewinds the current selection. * * @param TurnStateExecutionContext $context The context. */ protected function handleActions(TurnStateExecutionContext $context): void { - if (Input::isButtonDown("action")) { - $context->ui->state->confirm(); - $this->selectNextCharacter($context); + if (Input::isButtonDown('action')) { + $this->queueActionForActiveCharacter($context); } if (Input::isAnyKeyPressed([KeyCode::C, KeyCode::c])) { - $this->selectNextCharacter($context); - $context->ui->state->cancel(); + $this->selectPreviousCharacter($context); } } @@ -106,19 +106,127 @@ protected function handleActions(TurnStateExecutionContext $context): void */ protected function loadCharacterActions(): void { + if (! $this->activeCharacter) { + return; + } + /** @var TraditionalTurnBasedBattleEngine $engine */ $engine = $this->engine; $ui = $engine->battleConfig->ui; $ui->characterNameWindow->activeIndex = $this->activeCharacterIndex; - - $ui->commandWindow->commands = array_map(fn(BattleAction $action) => $action->name, $this->activeCharacter->commandAbilities); + $ui->commandWindow->commands = array_map( + fn(BattleAction $action) => $action->name, + $this->activeCharacter->commandAbilities + ); $ui->commandWindow->focus(); } - protected function selectNextCharacter(TurnStateExecutionContext $context): void + /** + * Moves to the next character who still needs an action. + * + * @param TurnStateExecutionContext $context The turn context. + * @param bool $resetToStart Whether to start from the beginning of the party. + * @return void + */ + protected function selectNextCharacter(TurnStateExecutionContext $context, bool $resetToStart = false): void + { + $partyBattlers = $context->party->battlers->toArray(); + $startIndex = $resetToStart ? 0 : $this->activeCharacterIndex + 1; + + foreach ($partyBattlers as $index => $battler) { + if ($index < $startIndex) { + continue; + } + + $turn = $context->findTurnForBattler($battler); + + if ($battler->isKnockedOut || $turn?->action !== null) { + continue; + } + + $this->activeCharacterIndex = $index; + $this->loadCharacterActions(); + $this->selectTarget($context); + return; + } + + $this->activeCharacterIndex = -1; + $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->commandWindow->blur(); + $context->ui->commandContextWindow->clear(); + $this->setState($this->engine->enemyActionState); + } + + /** + * Queues the selected action for the current character. + * + * @param TurnStateExecutionContext $context The turn context. + * @return void + */ + protected function queueActionForActiveCharacter(TurnStateExecutionContext $context): void + { + if (! $this->activeCharacter) { + return; + } + + $activeCommandIndex = $context->ui->commandWindow->activeCommandIndex; + $selectedAction = $this->activeCharacter->commandAbilities[$activeCommandIndex] ?? null; + $turn = $context->findTurnForBattler($this->activeCharacter); + $target = $this->getDefaultTarget($context); + + if ($selectedAction === null || $turn === null || $target === null) { + return; + } + + $turn->action = $selectedAction; + $turn->targets = [$target]; + + $context->ui->alert(sprintf('%s queued %s.', $this->activeCharacter->name, $selectedAction->name)); + $this->selectNextCharacter($context); + } + + /** + * Rewinds to the previous queued character so their action can be changed. + * + * @param TurnStateExecutionContext $context The turn context. + * @return void + */ + protected function selectPreviousCharacter(TurnStateExecutionContext $context): void + { + $partyBattlers = $context->party->battlers->toArray(); + $startIndex = $this->activeCharacterIndex < 0 ? count($partyBattlers) - 1 : $this->activeCharacterIndex - 1; + + for ($index = $startIndex; $index >= 0; $index--) { + $battler = $partyBattlers[$index]; + $turn = $context->findTurnForBattler($battler); + + if ($battler->isKnockedOut || $turn === null || $turn->action === null) { + continue; + } + + $turn->action = null; + $turn->targets = []; + $this->activeCharacterIndex = $index; + $this->loadCharacterActions(); + $this->selectTarget($context); + $context->ui->alert(sprintf('%s action cleared.', $battler->name)); + return; + } + + if ($this->activeCharacterIndex < 0) { + $this->selectNextCharacter($context, true); + } + } + + /** + * Returns the default target for the active character. + * + * @param TurnStateExecutionContext $context The turn context. + * @return CharacterInterface|null + */ + protected function getDefaultTarget(TurnStateExecutionContext $context): ?CharacterInterface { - $this->activeCharacterIndex = wrap($this->activeCharacterIndex + 1, 0, count($context->party->battlers) - 1); - $this->loadCharacterActions(); + return $context->getLivingTroopBattlers()[0] ?? null; } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php index 609fccf..f0ccd79 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php @@ -2,11 +2,9 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; -use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\TraditionalTurnBasedBattleEngine; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Turn; +use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; -use Ichiloto\Engine\Exceptions\NotImplementedException; -use Ichiloto\Engine\Util\Debug; /** * Represents the turn init state. @@ -21,6 +19,11 @@ class TurnInitState extends TurnState */ public function update(TurnStateExecutionContext $context): void { + if ($context->party->isDefeated() || $context->troop->isDefeated()) { + $this->setState($this->engine->turnResolutionState); + return; + } + $this->resetBuffsAndDebuffs($context); $this->determineTurnOrder($context); $this->updateUI($context); @@ -45,14 +48,23 @@ protected function resetBuffsAndDebuffs(TurnStateExecutionContext $context): voi protected function determineTurnOrder(TurnStateExecutionContext $context): void { $this->engine->turnQueue->clear(); + $turns = []; /** @var CharacterInterface[] $battlers */ - $battlers = [...$context->party->battlers->toArray(), ...$context->troop->members->toArray()]; - usort($battlers, fn(CharacterInterface $a, CharacterInterface $b) => $a->stats->speed <=> $b->stats->speed); + $battlers = array_values(array_filter( + [...$context->party->battlers->toArray(), ...$context->troop->members->toArray()], + fn(CharacterInterface $battler) => ! $battler->isKnockedOut + )); + + usort($battlers, fn(CharacterInterface $a, CharacterInterface $b) => $this->getBattlerSpeed($b) <=> $this->getBattlerSpeed($a)); + foreach ($battlers as $battler) { $turn = new Turn($battler); + $turns[] = $turn; $this->engine->turnQueue->enqueue($turn); } + + $context->setTurns($turns); } /** @@ -62,6 +74,20 @@ protected function determineTurnOrder(TurnStateExecutionContext $context): void */ private function updateUI(TurnStateExecutionContext $context): void { - // TODO: Implement updateUI() method. + $context->ui->characterStatusWindow->setCharacters($context->party->battlers->toArray()); + $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->commandContextWindow->clear(); + $context->ui->refresh(); + } + + /** + * Returns the battler's effective speed. + * + * @param CharacterInterface $battler The battler to inspect. + * @return int + */ + protected function getBattlerSpeed(CharacterInterface $battler): int + { + return $battler instanceof Character ? $battler->effectiveStats->speed : $battler->stats->speed; } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php index 8d37352..f332791 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php @@ -2,6 +2,9 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; +use Ichiloto\Engine\Battle\BattleResult; +use Ichiloto\Engine\Scenes\Battle\BattleScene; + /** * Represents the turn resolution state. * @@ -14,15 +17,59 @@ class TurnResolutionState extends TurnState */ public function update(TurnStateExecutionContext $context): void { - // TODO: Implement execute() method. - // Apply persistent effects + $scene = $context->game->sceneManager->currentScene; + + if (! $scene instanceof BattleScene) { + return; + } + + if ($context->troop->isDefeated()) { + $experience = 0; + $gold = 0; + $items = []; + + foreach ($context->troop->members->toArray() as $enemy) { + $experience += $enemy->rewards->experience; + $gold += $enemy->rewards->gold; + + if ($item = $enemy->rewards->item) { + $items[] = clone $item; + } + } + + foreach ($context->party->members->toArray() as $member) { + $member->addExperience($experience); + } + + $context->party->credit($gold); + + if (! empty($items)) { + $context->party->addItems(...$items); + } + + $lines = [ + sprintf('Experience gained: %d', $experience), + sprintf('Gold found: %dG', $gold), + ]; - // Resolve ongoing effects + if (! empty($items)) { + $lines[] = 'Loot: ' . implode(', ', array_map(fn($item) => $item->name, $items)); + } - // If battle is over + $scene->result = new BattleResult('Victory', $lines, $items); + $scene->setState($scene->victoryState); + return; + } - // else + if ($context->party->isDefeated()) { + $scene->result = new BattleResult('Defeat', [ + 'The party has been wiped out.', + 'Press enter to continue.', + ]); + $scene->setState($scene->defeatState); + return; + } - // transition to next turn + $this->setState($this->engine->turnInitState); } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnStateExecutionContext.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnStateExecutionContext.php index 227e798..ef988d7 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnStateExecutionContext.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnStateExecutionContext.php @@ -2,8 +2,10 @@ namespace Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States; +use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Turn; use Ichiloto\Engine\Battle\UI\BattleScreen; use Ichiloto\Engine\Core\Game; +use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Troop; @@ -14,6 +16,15 @@ */ class TurnStateExecutionContext { + /** + * @var Turn[] The turns to resolve this round. + */ + protected array $turns = []; + /** + * @var int The current turn cursor. + */ + protected int $turnCursor = 0; + /** * TurnStateExecutionContext constructor. * @@ -32,4 +43,115 @@ public function __construct( ) { } -} \ No newline at end of file + + /** + * Sets the turns for the current round. + * + * @param Turn[] $turns The turns to resolve. + * @return void + */ + public function setTurns(array $turns): void + { + $this->turns = array_values($turns); + $this->turnCursor = 0; + } + + /** + * Returns the current round turns. + * + * @return Turn[] + */ + public function getTurns(): array + { + return $this->turns; + } + + /** + * Resets the turn cursor. + * + * @return void + */ + public function resetTurnCursor(): void + { + $this->turnCursor = 0; + } + + /** + * Returns the current turn. + * + * @return Turn|null + */ + public function getCurrentTurn(): ?Turn + { + return $this->turns[$this->turnCursor] ?? null; + } + + /** + * Advances to the next turn. + * + * @return void + */ + public function advanceTurn(): void + { + $this->turnCursor++; + } + + /** + * Returns the turn for the given battler. + * + * @param CharacterInterface $battler The battler to find. + * @return Turn|null + */ + public function findTurnForBattler(CharacterInterface $battler): ?Turn + { + foreach ($this->turns as $turn) { + if ($turn->battler === $battler) { + return $turn; + } + } + + return null; + } + + /** + * Returns the living party battlers. + * + * @return CharacterInterface[] + */ + public function getLivingPartyBattlers(): array + { + return array_values(array_filter( + $this->party->battlers->toArray(), + fn(CharacterInterface $battler) => ! $battler->isKnockedOut + )); + } + + /** + * Returns the living troop battlers. + * + * @return CharacterInterface[] + */ + public function getLivingTroopBattlers(): array + { + return array_values(array_filter( + $this->troop->members->toArray(), + fn(CharacterInterface $battler) => ! $battler->isKnockedOut + )); + } + + /** + * Returns the living opponents for the given battler. + * + * @param CharacterInterface $battler The battler whose opponents are required. + * @return CharacterInterface[] + */ + public function getLivingOpponents(CharacterInterface $battler): array + { + $partyBattlers = $this->party->battlers->toArray(); + $isPartyBattler = in_array($battler, $partyBattlers, true); + + return $isPartyBattler + ? $this->getLivingTroopBattlers() + : $this->getLivingPartyBattlers(); + } +} diff --git a/src/Battle/Engines/TurnBasedEngines/Turn.php b/src/Battle/Engines/TurnBasedEngines/Turn.php index 988e8bc..66a4e04 100644 --- a/src/Battle/Engines/TurnBasedEngines/Turn.php +++ b/src/Battle/Engines/TurnBasedEngines/Turn.php @@ -55,6 +55,8 @@ public function execute(TurnExecutionContext $context): void { if (!$this->action) { $context->battleConfig->ui->alert('No action set for turn.'); + $this->complete(); + return; } $this->action->execute($this->battler, $this->targets); @@ -71,4 +73,4 @@ public function complete(): void { $this->isCompleted = true; } -} \ No newline at end of file +} diff --git a/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php b/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php index 6d6ea8e..d74e206 100644 --- a/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php +++ b/src/Battle/Engines/TurnBasedEngines/TurnBasedEngine.php @@ -11,13 +11,8 @@ use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnState; use Ichiloto\Engine\Battle\Engines\TurnBasedEngines\Traditional\States\TurnStateExecutionContext; use Ichiloto\Engine\Battle\Interfaces\BattleEngineInterface; -use Ichiloto\Engine\Battle\PartyBattlerPositions; use Ichiloto\Engine\Core\Game; -use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; -use Ichiloto\Engine\Exceptions\NotFoundException; use Ichiloto\Engine\Scenes\Battle\BattleConfig; -use Ichiloto\Engine\Scenes\Game\GameScene; /** * Class TurnBasedEngine. A base class for turn-based battle engines. @@ -96,11 +91,13 @@ public function start(): void /** * @inheritDoc - * @throws NotFoundException If the game scene cannot be found. */ public function stop(): void { - $this->game->sceneManager->loadScene(GameScene::class); + $this->turnQueue->clear(); + $this->state = null; + $this->turnStateExecutionContext = null; + $this->battleConfig = null; } /** @@ -137,7 +134,6 @@ protected function initializeTurnStates(): void */ protected function positionBattlers(): void { - $this->battleConfig->ui->fieldWindow->renderParty($this->battleConfig->party); - $this->battleConfig->ui->fieldWindow->renderTroop($this->battleConfig->troop); + $this->battleConfig->ui->refreshField(); } -} \ No newline at end of file +} diff --git a/src/Battle/Enumerations/BattleActionCategory.php b/src/Battle/Enumerations/BattleActionCategory.php new file mode 100644 index 0000000..7069a6a --- /dev/null +++ b/src/Battle/Enumerations/BattleActionCategory.php @@ -0,0 +1,73 @@ +name); + + return match (true) { + str_contains($name, 'summon'), str_contains($name, 'esper') => self::SUMMON, + str_contains($name, 'flare'), str_contains($name, 'ultima'), str_contains($name, 'meteor') => self::HIGH_MAGIC, + str_contains($name, 'magic'), str_contains($name, 'spell') => self::BASIC_MAGIC, + default => self::PHYSICAL_ATTACK, + }; + } + + /** + * Returns the total turn duration in seconds for the provided pace. + * + * @param BattlePace $pace The active animation pace. + * @return float + */ + public function totalDurationSeconds(BattlePace $pace): float + { + return match ($this) { + self::PHYSICAL_ATTACK => match ($pace) { + BattlePace::FAST => 1.5, + BattlePace::MEDIUM => 2.5, + BattlePace::SLOW => 4.0, + }, + self::BASIC_MAGIC => match ($pace) { + BattlePace::FAST => 2.0, + BattlePace::MEDIUM => 3.5, + BattlePace::SLOW => 5.5, + }, + self::HIGH_MAGIC => match ($pace) { + BattlePace::FAST => 4.0, + BattlePace::MEDIUM => 6.0, + BattlePace::SLOW => 9.0, + }, + self::SUMMON => match ($pace) { + BattlePace::FAST => 6.0, + BattlePace::MEDIUM => 10.0, + BattlePace::SLOW => 15.0, + }, + }; + } +} diff --git a/src/Battle/Enumerations/BattlePace.php b/src/Battle/Enumerations/BattlePace.php new file mode 100644 index 0000000..077341a --- /dev/null +++ b/src/Battle/Enumerations/BattlePace.php @@ -0,0 +1,62 @@ + self::FAST, + $value <= 4 => self::MEDIUM, + default => self::SLOW, + }; + } + + if (! is_string($value)) { + return $default; + } + + return match (strtolower(trim($value))) { + 'fast' => self::FAST, + 'medium' => self::MEDIUM, + 'slow' => self::SLOW, + default => $default, + }; + } + + /** + * Returns the representative info-panel duration in seconds for this pace. + * + * @return float + */ + public function messageDurationSeconds(): float + { + return match ($this) { + self::FAST => 0.65, + self::MEDIUM => 1.25, + self::SLOW => 2.5, + }; + } +} diff --git a/src/Battle/PartyBattlerPositions.php b/src/Battle/PartyBattlerPositions.php index 80e6832..5da5b5d 100644 --- a/src/Battle/PartyBattlerPositions.php +++ b/src/Battle/PartyBattlerPositions.php @@ -24,11 +24,11 @@ public function __construct( new Vector2(109, 21), ], public array $activePositions = [ - new Vector2(109, 5), - new Vector2(105, 13), - new Vector2(109, 21), + new Vector2(103, 5), + new Vector2(99, 13), + new Vector2(103, 21), ] ) { } -} \ No newline at end of file +} diff --git a/src/Battle/UI/BattleCharacterStatusWindow.php b/src/Battle/UI/BattleCharacterStatusWindow.php index 4ba8d34..cb35488 100644 --- a/src/Battle/UI/BattleCharacterStatusWindow.php +++ b/src/Battle/UI/BattleCharacterStatusWindow.php @@ -96,8 +96,10 @@ public function formatCharacterStats(Character $character): string { $hpProgressBar = new ProgressBar($this->camera, 10); $mpProgressBar = new ProgressBar($this->camera, 5); - $hpPercentage = $character->effectiveStats->currentHp / $character->effectiveStats->totalHp; - $mpPercentage = $character->effectiveStats->currentMp / $character->effectiveStats->totalMp; + $hpTotal = max(1, $character->effectiveStats->totalHp); + $mpTotal = max(1, $character->effectiveStats->totalMp); + $hpPercentage = $character->effectiveStats->currentHp / $hpTotal; + $mpPercentage = $character->effectiveStats->currentMp / $mpTotal; $hpProgressBar->fill($hpPercentage); $mpProgressBar->fill($mpPercentage); @@ -109,4 +111,4 @@ public function formatCharacterStats(Character $character): string $mpProgressBar->getRender() ); } -} \ No newline at end of file +} diff --git a/src/Battle/UI/BattleCommandContextWindow.php b/src/Battle/UI/BattleCommandContextWindow.php index f5526e5..93347dc 100644 --- a/src/Battle/UI/BattleCommandContextWindow.php +++ b/src/Battle/UI/BattleCommandContextWindow.php @@ -26,4 +26,17 @@ public function __construct(protected BattleScreen $battleScreen) $this->battleScreen->borderPack ); } -} \ No newline at end of file + + /** + * Clears the context window while preserving its reserved layout space. + * + * @return void + */ + public function clear(): void + { + $this->setTitle(''); + $this->setHelp(''); + $this->setContent(array_fill(0, self::HEIGHT - 2, '')); + $this->render(); + } +} diff --git a/src/Battle/UI/BattleCommandWindow.php b/src/Battle/UI/BattleCommandWindow.php index 1a6f989..f512d47 100644 --- a/src/Battle/UI/BattleCommandWindow.php +++ b/src/Battle/UI/BattleCommandWindow.php @@ -69,7 +69,7 @@ public function __construct(protected BattleScreen $battleScreen) */ public function focus(): void { - $this->activeCommandIndex = 0; + $this->activeCommandIndex = $this->totalCommands > 0 ? 0 : -1; $this->updateContent(); } @@ -78,13 +78,16 @@ public function focus(): void */ public function blur(): void { - // TODO: Implement blur() method. + $this->activeCommandIndex = -1; + $this->updateContent(); } public function clear(): void { - $this->setContent([]); - $this->render(); + $this->commands = []; + $this->activeCommandIndex = -1; + $this->totalCommands = 0; + $this->updateContent(); } /** @@ -111,6 +114,10 @@ public function updateContent(): void */ public function selectPrevious(): void { + if ($this->totalCommands < 1) { + return; + } + $index = wrap($this->activeCommandIndex - 1, 0, $this->totalCommands - 1); $this->activeCommandIndex = $index; $this->updateContent(); @@ -121,8 +128,12 @@ public function selectPrevious(): void */ public function selectNext(): void { + if ($this->totalCommands < 1) { + return; + } + $index = wrap($this->activeCommandIndex + 1, 0, $this->totalCommands - 1); $this->activeCommandIndex = $index; $this->updateContent(); } -} \ No newline at end of file +} diff --git a/src/Battle/UI/BattleFieldWindow.php b/src/Battle/UI/BattleFieldWindow.php index 2245140..6dcea58 100644 --- a/src/Battle/UI/BattleFieldWindow.php +++ b/src/Battle/UI/BattleFieldWindow.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Windows\Window; use RuntimeException; @@ -19,6 +20,7 @@ */ class BattleFieldWindow extends Window { + const int TROOP_STEP_X_OFFSET = 3; /** * The width of the window. */ @@ -127,7 +129,7 @@ protected function eraseBattlerSprite(array $spriteData, int $x, int $y): void { foreach ($spriteData as $rowIndex => $row) { Console::cursor()->moveTo($x, $y + $rowIndex); - $output = str_repeat(' ', mb_strlen($row)); + $output = str_repeat(' ', TerminalText::displayWidth($row)); $this->output->write($output); } } @@ -148,6 +150,39 @@ protected function renderBattlerSprite(array $spriteData, float|int $x, float|in } } + /** + * Returns the idle position of the specified party battler. + * + * @param int $index The battler index. + * @return Vector2 + */ + protected function getPartyIdlePosition(int $index): Vector2 + { + return $this->partyBattlerPositions->idlePositions[$index] ?? throw new RuntimeException('Invalid party battler position.'); + } + + /** + * Returns the active position of the specified party battler. + * + * @param int $index The battler index. + * @return Vector2 + */ + protected function getPartyActivePosition(int $index): Vector2 + { + return $this->partyBattlerPositions->activePositions[$index] ?? throw new RuntimeException('Invalid party battler active position.'); + } + + /** + * Returns the active position of the specified troop battler. + * + * @param Enemy $battler The battler to inspect. + * @return Vector2 + */ + protected function getTroopActivePosition(Enemy $battler): Vector2 + { + return new Vector2($battler->position->x + self::TROOP_STEP_X_OFFSET, $battler->position->y); + } + /** * Renders the party on the battle screen. * @@ -157,9 +192,13 @@ protected function renderBattlerSprite(array $spriteData, float|int $x, float|in public function renderParty(Party $party): void { foreach ($party->battlers->toArray() as $index => $battler) { + if ($battler->isKnockedOut) { + continue; + } + $this->renderPartyBattler( $battler, - $this->partyBattlerPositions->idlePositions[$index] ?? throw new RuntimeException('Invalid party battler position.') + $this->getPartyIdlePosition($index) ); } } @@ -173,10 +212,74 @@ public function renderParty(Party $party): void public function renderTroop(Troop $troop): void { foreach ($troop->members->toArray() as $battler) { + if ($battler->isKnockedOut) { + continue; + } + $this->renderTroopBattler($battler); } } + /** + * Steps the specified party battler forward. + * + * @param Character $battler The battler to move. + * @param int $index The battler index. + * @return void + */ + public function stepPartyBattlerForward(Character $battler, int $index): void + { + $this->erasePartyBattler($battler, $this->getPartyIdlePosition($index)); + $this->renderPartyBattler($battler, $this->getPartyActivePosition($index)); + } + + /** + * Returns the specified party battler to idle position. + * + * @param Character $battler The battler to move. + * @param int $index The battler index. + * @return void + */ + public function stepPartyBattlerBack(Character $battler, int $index): void + { + $this->erasePartyBattler($battler, $this->getPartyActivePosition($index)); + $this->renderPartyBattler($battler, $this->getPartyIdlePosition($index)); + } + + /** + * Steps the specified enemy battler forward. + * + * @param Enemy $battler The battler to move. + * @return void + */ + public function stepTroopBattlerForward(Enemy $battler): void + { + $this->eraseTroopBattler($battler); + $activePosition = $this->getTroopActivePosition($battler); + $this->renderBattlerSprite( + $battler->image, + $this->position->x + $activePosition->x, + $this->position->y + $activePosition->y + ); + } + + /** + * Returns the specified enemy battler to idle position. + * + * @param Enemy $battler The battler to move. + * @return void + */ + public function stepTroopBattlerBack(Enemy $battler): void + { + $activePosition = $this->getTroopActivePosition($battler); + $this->eraseBattlerSprite( + $battler->image, + $this->position->x + $activePosition->x, + $this->position->y + $activePosition->y + ); + $this->renderTroopBattler($battler); + } + /** * Selects a party battler. The selected battler will be indicated by a cursor and will step forward. * @@ -231,4 +334,4 @@ public function focusOnTroopBattler(int $index): void { // TODO: Implement focusOnTroopBattler() method. } -} \ No newline at end of file +} diff --git a/src/Battle/UI/BattleResultWindow.php b/src/Battle/UI/BattleResultWindow.php new file mode 100644 index 0000000..7581ae8 --- /dev/null +++ b/src/Battle/UI/BattleResultWindow.php @@ -0,0 +1,56 @@ +battleScreen->screenDimensions->getLeft() + intval((BattleScreen::WIDTH - self::WIDTH) / 2); + $topMargin = $this->battleScreen->screenDimensions->getTop() + intval((BattleScreen::HEIGHT - self::HEIGHT) / 2); + + parent::__construct( + '', + 'enter:Continue', + new Vector2($leftMargin, $topMargin), + self::WIDTH, + self::HEIGHT, + $this->battleScreen->borderPack, + WindowAlignment::middleLeft() + ); + } + + /** + * Displays the provided battle result. + * + * @param BattleResult $result The battle result to display. + * @return void + */ + public function display(BattleResult $result): void + { + $content = []; + $this->setTitle($result->title); + + foreach ($result->lines as $line) { + $content = array_merge($content, explode("\n", wrap_text($line, self::WIDTH - 4))); + } + + $content = array_slice($content, 0, self::HEIGHT - 2); + $content = array_pad($content, self::HEIGHT - 2, ''); + $this->setContent($content); + $this->render(); + } +} diff --git a/src/Battle/UI/BattleScreen.php b/src/Battle/UI/BattleScreen.php index a885f7f..1f789cd 100644 --- a/src/Battle/UI/BattleScreen.php +++ b/src/Battle/UI/BattleScreen.php @@ -2,6 +2,7 @@ namespace Ichiloto\Engine\Battle\UI; +use Ichiloto\Engine\Battle\BattlePacing; use Ichiloto\Engine\Battle\PartyBattlerPositions; use Ichiloto\Engine\Battle\UI\States\BattleScreenState; use Ichiloto\Engine\Battle\UI\States\PlayerActionState; @@ -78,6 +79,14 @@ class BattleScreen implements CanRender, CanUpdate return $this->battleScene->party ?? throw new RuntimeException('The party is not set in the battle scene.'); } } + /** + * @var Troop The troop in the battle scene. + */ + public Troop $troop { + get { + return $this->battleScene->troop ?? throw new RuntimeException('The troop is not set in the battle scene.'); + } + } /** * @var BattleScreenState|null The state of the battle screen. */ @@ -96,7 +105,7 @@ class BattleScreen implements CanRender, CanUpdate if ($this->isAlerting) { $this->alertHideTime = Time::getTime() + $this->alertDuration; } else { - $this->messageWindow->hide(); + $this->hideMessage(); } } } @@ -104,10 +113,18 @@ class BattleScreen implements CanRender, CanUpdate * @var float The duration of the message. */ protected float $alertDuration = 3.0; // seconds + /** + * @var BattlePacing The active pacing profile. + */ + protected BattlePacing $pacing; /** * @var float The time to hide the message. */ protected float $alertHideTime = 0; + /** + * @var bool Whether the info panel is visible. + */ + protected bool $isMessageVisible = false; /** * @var Camera The camera. */ @@ -140,6 +157,8 @@ public function __construct(protected BattleScene $battleScene) } $this->borderPack = $borderPack; + $this->pacing = BattlePacing::fromConfig(); + $this->alertDuration = $this->pacing->getMessageDurationSeconds(); $this->initializeWindows(); $this->initializeScreenStates(); } @@ -203,7 +222,7 @@ public function setState(BattleScreenState $state): void */ public function render(): void { - $this->fieldWindow->render(); + $this->renderField(); $this->showControls(); } @@ -213,7 +232,7 @@ public function render(): void public function erase(): void { $this->fieldWindow->erase(); - $this->messageWindow->erase(); + $this->hideMessage(); $this->hideControls(); } @@ -222,6 +241,8 @@ public function erase(): void */ public function update(): void { + $this->state?->update(); + if ($this->isAlerting) { if (Time::getTime() >= $this->alertHideTime) { $this->isAlerting = false; @@ -237,12 +258,92 @@ public function update(): void */ public function alert(string $text): void { - $this->messageWindow->setText($text); + $this->showMessage($text); $this->isAlerting = true; } + /** + * Shows the provided text in the info panel until it is hidden. + * + * @param string $text The text to display. + * @return void + */ + public function showMessage(string $text): void + { + if ($this->isAlerting) { + $this->isAlerting = false; + } + + $this->isMessageVisible = true; + $this->messageWindow->setText($text); + } + + /** + * Hides the info panel. + * + * @return void + */ + public function hideMessage(): void + { + $this->isMessageVisible = false; + $this->messageWindow->hide(); + } + + /** + * Returns the pacing profile for the battle screen. + * + * @return BattlePacing + */ + public function getPacing(): BattlePacing + { + return $this->pacing; + } + + /** + * Renders the battlefield and all active battlers. + * + * @return void + */ + public function renderField(): void + { + $this->fieldWindow->render(); + $this->fieldWindow->renderParty($this->party); + $this->fieldWindow->renderTroop($this->troop); + } + + /** + * Refreshes the battle UI without rebuilding its state. + * + * @return void + */ + public function refresh(): void + { + $this->fieldWindow->erase(); + $this->renderField(); + $this->showControls(); + + if ($this->isMessageVisible) { + $this->messageWindow->render(); + } + } + + /** + * Refreshes only the battlefield. + * + * @return void + */ + public function refreshField(): void + { + $this->fieldWindow->erase(); + $this->renderField(); + + if ($this->isMessageVisible) { + $this->messageWindow->render(); + } + } + protected function initializeScreenStates(): void { $this->playerActionState = new PlayerActionState($this); } -} \ No newline at end of file +} diff --git a/src/Core/Game.php b/src/Core/Game.php index e6cbf22..6c7334a 100644 --- a/src/Core/Game.php +++ b/src/Core/Game.php @@ -157,6 +157,10 @@ public function __destruct() public function configure(array $options): self { $this->options = array_merge_recursive($this->options, $options); + ['width' => $this->width, 'height' => $this->height] = $this->resolveScreenSize($this->options); + $this->options['width'] = $this->width; + $this->options['height'] = $this->height; + $this->options['screen'] = ['width' => $this->width, 'height' => $this->height]; foreach ($this->options as $key => $value) { ConfigStore::get(PlaySettings::class)->set($key, $value); @@ -259,6 +263,52 @@ protected function stop(): void $this->isRunning = false; } + /** + * Resolves the screen size that should be used for the current session. + * + * Default constructor dimensions are treated as "auto", which allows the + * engine to adopt the full size of the user's terminal on boot. + * + * @param array $options The current game options. + * @return array{width: int, height: int} The resolved screen size. + */ + protected function resolveScreenSize(array $options): array + { + $availableSize = Console::getAvailableSize(); + $requestedWidth = $options['width'] ?? $options['screen']['width'] ?? $this->width; + $requestedHeight = $options['height'] ?? $options['screen']['height'] ?? $this->height; + + return [ + 'width' => $this->resolveScreenDimension($requestedWidth, $availableSize['width'], DEFAULT_SCREEN_WIDTH), + 'height' => $this->resolveScreenDimension($requestedHeight, $availableSize['height'], DEFAULT_SCREEN_HEIGHT), + ]; + } + + /** + * Resolves an individual screen dimension. + * + * @param mixed $requestedDimension The configured dimension. + * @param int $availableDimension The current terminal dimension. + * @param int $defaultDimension The legacy default dimension. + * @return int The resolved dimension. + */ + protected function resolveScreenDimension( + mixed $requestedDimension, + int $availableDimension, + int $defaultDimension + ): int + { + if (is_numeric($requestedDimension)) { + $requestedDimension = intval($requestedDimension); + + if ($requestedDimension > 0 && $requestedDimension !== $defaultDimension) { + return $requestedDimension; + } + } + + return max(1, $availableDimension); + } + /** * Handle the input. * @@ -277,12 +327,46 @@ protected function handleInput(): void protected function update(): void { $this->frameCount++; + $this->syncScreenSize(); $this->sceneManager->update(); $this->notificationManager->update(); $this->notify($this, new GameEvent(GameEventType::UPDATE)); } + /** + * Synchronizes the engine with the current terminal size. + * + * @return void + */ + protected function syncScreenSize(): void + { + $availableSize = Console::getAvailableSize(); + + if ($availableSize['width'] === $this->width && $availableSize['height'] === $this->height) { + return; + } + + $this->width = $availableSize['width']; + $this->height = $availableSize['height']; + $this->options['width'] = $this->width; + $this->options['height'] = $this->height; + $this->options['screen'] = ['width' => $this->width, 'height' => $this->height]; + + ConfigStore::get(PlaySettings::class)->set('width', $this->width); + ConfigStore::get(PlaySettings::class)->set('height', $this->height); + ConfigStore::get(PlaySettings::class)->set('screen.width', $this->width); + ConfigStore::get(PlaySettings::class)->set('screen.height', $this->height); + + Console::syncDimensions($this->width, $this->height); + + $currentScene = $this->sceneManager->currentScene; + + if ($currentScene && method_exists($currentScene, 'onScreenResize')) { + $currentScene->onScreenResize($this->width, $this->height); + } + } + /** * Render the game. * @@ -652,4 +736,4 @@ private function buildItemStore(): void $this->itemStore = $itemStore; } } -} \ No newline at end of file +} diff --git a/src/Core/Menu/EquipmentMenu/Modes/EquipmentMenuCommandSelectionMode.php b/src/Core/Menu/EquipmentMenu/Modes/EquipmentMenuCommandSelectionMode.php index 6bf1391..1d716a2 100644 --- a/src/Core/Menu/EquipmentMenu/Modes/EquipmentMenuCommandSelectionMode.php +++ b/src/Core/Menu/EquipmentMenu/Modes/EquipmentMenuCommandSelectionMode.php @@ -61,6 +61,7 @@ public function update(): void public function enter(): void { $this->state->equipmentMenu->setActiveItemByIndex(0); + $this->state->equipmentCommandPanel->updateContent(); $this->state->equipmentInfoPanel->setText($this->state->activeMenuCommand->getDescription()); } @@ -97,4 +98,4 @@ protected function selectNext(): void $this->state->equipmentCommandPanel->updateContent(); $this->state->equipmentInfoPanel->setText($this->state->activeMenuCommand->getDescription()); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php b/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php index 3122b3b..33d1ba4 100644 --- a/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php +++ b/src/Core/Menu/EquipmentMenu/Modes/EquipmentSelectionMode.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\Entities\Inventory\Equipment; use Ichiloto\Engine\Entities\Inventory\Inventory; use Ichiloto\Engine\Entities\Stats; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use RuntimeException; @@ -36,6 +37,10 @@ class EquipmentSelectionMode extends EquipmentMenuMode implements CanRender * @var Equipment[] The compatible equipment. */ protected array $compatibleEquipment = []; + /** + * @var array The currently available quantities for the selection list. + */ + protected array $availableQuantities = []; /** * @var int The active index. */ @@ -76,6 +81,8 @@ public function enter(): void $this->updateCharacterDetailPanel(); if ($this->activeEquipment) { $this->state->equipmentInfoPanel->setText($this->activeEquipment->description ?? ''); + } else { + $this->state->equipmentInfoPanel->setText('No compatible equipment available.'); } $this->render(); } @@ -101,7 +108,25 @@ public function exit(): void */ public function getCompatibleEquipment(Inventory $inventory, string $equipmentType): array { - $compatibleEquipment = array_filter($inventory->equipment->toArray(), fn(Equipment $equipment) => is_a($equipment, $equipmentType)); + $compatibleEquipment = []; + $this->availableQuantities = []; + $currentEquipment = $this->equipmentSlot?->equipment; + + foreach ($inventory->equipment->toArray() as $equipment) { + assert($equipment instanceof Equipment); + + if (! is_a($equipment, $equipmentType)) { + continue; + } + + $availableQuantity = $this->state->party->getAvailableEquipmentQuantity($equipment); + $this->availableQuantities[$this->getEquipmentKey($equipment)] = $availableQuantity; + + if ($availableQuantity > 0 || $this->equipmentMatches($equipment, $currentEquipment)) { + $compatibleEquipment[] = $equipment; + } + } + $this->totalEquipment = count($compatibleEquipment); return $compatibleEquipment; @@ -114,6 +139,10 @@ public function getCompatibleEquipment(Inventory $inventory, string $equipmentTy */ public function selectPrevious(): void { + if ($this->totalEquipment < 1) { + return; + } + $this->activeIndex = wrap($this->activeIndex - 1, 0, $this->totalEquipment - 1); } @@ -124,6 +153,10 @@ public function selectPrevious(): void */ public function selectNext(): void { + if ($this->totalEquipment < 1) { + return; + } + $this->activeIndex = wrap($this->activeIndex + 1, 0, $this->totalEquipment - 1); } @@ -138,7 +171,9 @@ public function render(): void foreach ($this->compatibleEquipment as $index => $equipment) { $prefix = $index === $this->activeIndex ? '>' : ' '; - $content[$index] = sprintf(" %s %-58s :%02d", $prefix, "{$equipment->icon} {$equipment->name}", $equipment->quantity); + $equipmentName = TerminalText::padRight("{$equipment->icon} {$equipment->name}", 58); + $quantity = TerminalText::padLeft((string)$this->getAvailableQuantity($equipment), 2); + $content[$index] = " {$prefix} {$equipmentName} :{$quantity}"; } $this->state->equipmentAssignmentPanel->setContent($content); @@ -167,7 +202,17 @@ protected function handleActions(): void if (Input::isButtonDown("confirm")) { if ($this->activeEquipment) { - $this->character->equip($this->activeEquipment); + if ($this->isCurrentEquipmentSelected()) { + $this->character->unequip($this->equipmentSlot ?? throw new RuntimeException('Equipment slot cannot be null.')); + } else if ($this->getAvailableQuantity($this->activeEquipment) < 1) { + alert(sprintf('%s is out of stock.', $this->activeEquipment->name)); + return; + } else { + $this->character->equipInSlot( + $this->equipmentSlot ?? throw new RuntimeException('Equipment slot cannot be null.'), + $this->activeEquipment + ); + } } else { $this->character->unequip($this->equipmentSlot); } @@ -193,6 +238,8 @@ protected function handleNavigation(): void $this->updateCharacterDetailPanel(); if ($this->activeEquipment) { $this->state->equipmentInfoPanel->setText($this->activeEquipment->description); + } else { + $this->state->equipmentInfoPanel->setText('No compatible equipment available.'); } $this->render(); } @@ -205,17 +252,98 @@ protected function handleNavigation(): void */ protected function updateCharacterDetailPanel(): void { - $previewStats = new Stats( - attack: $this->character?->stats->attack + $this->activeEquipment?->parameterChanges->attack, - defence: $this->character?->stats->defence + $this->activeEquipment?->parameterChanges->defence, - magicAttack: $this->character?->stats->magicAttack + $this->activeEquipment?->parameterChanges->magicAttack, - magicDefence: $this->character?->stats->magicDefence + $this->activeEquipment?->parameterChanges->magicDefence, - speed: $this->character?->stats->speed + $this->activeEquipment?->parameterChanges->speed, - grace: $this->character?->stats->grace + $this->activeEquipment?->parameterChanges->grace, - evasion: $this->character?->stats->evasion + $this->activeEquipment?->parameterChanges->evasion, - totalHp: $this->character?->stats->totalHp + $this->activeEquipment?->parameterChanges->totalHp, - totalMp: $this->character?->stats->totalMp + $this->activeEquipment?->parameterChanges->totalMp, - ); + if (! $this->character) { + return; + } + + if (! $this->activeEquipment) { + $this->state->characterDetailPanel->setDetails($this->character); + return; + } + + $previewStats = clone $this->character->effectiveStats; + $currentEquipment = $this->equipmentSlot?->equipment; + + if ($currentEquipment instanceof Equipment) { + $this->applyEquipmentChanges($previewStats, $currentEquipment, -1); + } + + if (! $this->isCurrentEquipmentSelected()) { + $this->applyEquipmentChanges($previewStats, $this->activeEquipment, 1); + } + $this->state->characterDetailPanel->setDetails($this->character, $previewStats); } -} \ No newline at end of file + + /** + * Applies or removes an equipment's parameter changes from a preview stat block. + * + * @param Stats $stats The stats to modify. + * @param Equipment $equipment The equipment whose parameters should be applied. + * @param int $direction Use `1` to add the equipment and `-1` to remove it. + * @return void + */ + protected function applyEquipmentChanges(Stats $stats, Equipment $equipment, int $direction): void + { + $stats->attack += ($equipment->parameterChanges->attack ?? 0) * $direction; + $stats->defence += ($equipment->parameterChanges->defence ?? 0) * $direction; + $stats->magicAttack += ($equipment->parameterChanges->magicAttack ?? 0) * $direction; + $stats->magicDefence += ($equipment->parameterChanges->magicDefence ?? 0) * $direction; + $stats->speed += ($equipment->parameterChanges->speed ?? 0) * $direction; + $stats->grace += ($equipment->parameterChanges->grace ?? 0) * $direction; + $stats->evasion += ($equipment->parameterChanges->evasion ?? 0) * $direction; + $stats->totalHp += ($equipment->parameterChanges->totalHp ?? 0) * $direction; + $stats->totalMp += ($equipment->parameterChanges->totalMp ?? 0) * $direction; + } + + /** + * Returns the currently available quantity for an equipment entry. + * + * @param Equipment $equipment The equipment entry. + * @return int The number of unequipped copies still available. + */ + protected function getAvailableQuantity(Equipment $equipment): int + { + return $this->availableQuantities[$this->getEquipmentKey($equipment)] ?? 0; + } + + /** + * Builds a stable key for availability lookups. + * + * @param Equipment $equipment The equipment to identify. + * @return string The lookup key. + */ + protected function getEquipmentKey(Equipment $equipment): string + { + return $equipment::class . ':' . $equipment->name; + } + + /** + * Determines whether the selected item matches what the slot already has equipped. + * + * @return bool True if selecting this item should toggle the slot off. + */ + protected function isCurrentEquipmentSelected(): bool + { + return $this->equipmentMatches($this->activeEquipment, $this->equipmentSlot?->equipment); + } + + /** + * Compares two equipment entries by type and name. + * + * Inventory equipment is stack-based, so matching by class and name is the + * most reliable way to determine whether two entries represent the same item. + * + * @param Equipment|null $first The first equipment entry. + * @param Equipment|null $second The second equipment entry. + * @return bool True if both entries represent the same equipment item. + */ + protected function equipmentMatches(?Equipment $first, ?Equipment $second): bool + { + if (! $first instanceof Equipment || ! $second instanceof Equipment) { + return false; + } + + return $first::class === $second::class && $first->name === $second->name; + } +} diff --git a/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php b/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php index 235ca93..2b4e4bc 100644 --- a/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php +++ b/src/Core/Menu/EquipmentMenu/Windows/CharacterDetailPanel.php @@ -16,6 +16,11 @@ */ class CharacterDetailPanel extends Window { + /** + * The width reserved for each stat value column. + */ + private const int STAT_VALUE_WIDTH = 6; + /** * @var Character|null The character to display. */ @@ -91,42 +96,72 @@ public function updateContent(): void "", "", "", - sprintf(" %-13s%4s -> %4s", 'HP', $this->character?->effectiveStats->totalHp ?? '', $this->highlightStat($this->character?->effectiveStats->totalHp, $totalHp)), - sprintf(" %-15s%2s -> %3s", 'MP', $this->character?->effectiveStats->totalMp ?? '', $this->highlightStat($this->character?->effectiveStats->totalMp, $totalMp)), - sprintf(" %-15s%2s -> %3s", 'Attack', $this->character?->effectiveStats->attack ?? '', $this->highlightStat($this->character?->effectiveStats->attack, $attack)), - sprintf(" %-15s%2s -> %3s", 'Defence', $this->character?->effectiveStats->defence ?? '', $this->highlightStat($this->character?->effectiveStats->defence, $defence)), - sprintf(" %-15s%2s -> %3s", 'M.Attack', $this->character?->effectiveStats->magicAttack ?? '', $this->highlightStat($this->character?->effectiveStats->magicAttack, $magicAttack)), - sprintf(" %-15s%2s -> %3s", 'M.Defence', $this->character?->effectiveStats->magicDefence ?? '', $this->highlightStat($this->character?->effectiveStats->magicDefence, $magicDefence)), - sprintf(" %-15s%2s -> %3s", 'Evasion', $this->character?->effectiveStats->evasion ?? '', $this->highlightStat($this->character?->effectiveStats->evasion, $evasion)), - sprintf(" %-15s%2s -> %3s", 'Speed', $this->character?->effectiveStats->speed ?? '', $this->highlightStat($this->character?->effectiveStats->speed, $speed)), - sprintf(" %-15s%2s -> %3s", 'Grace', $this->character?->effectiveStats->grace ?? '', $this->highlightStat($this->character?->effectiveStats->grace, $grace)), + $this->formatStatLine('HP', $this->character?->effectiveStats->totalHp, $totalHp), + $this->formatStatLine('MP', $this->character?->effectiveStats->totalMp, $totalMp), + $this->formatStatLine('Attack', $this->character?->effectiveStats->attack, $attack), + $this->formatStatLine('Defence', $this->character?->effectiveStats->defence, $defence), + $this->formatStatLine('M.Attack', $this->character?->effectiveStats->magicAttack, $magicAttack), + $this->formatStatLine('M.Defence', $this->character?->effectiveStats->magicDefence, $magicDefence), + $this->formatStatLine('Evasion', $this->character?->effectiveStats->evasion, $evasion), + $this->formatStatLine('Speed', $this->character?->effectiveStats->speed, $speed), + $this->formatStatLine('Grace', $this->character?->effectiveStats->grace, $grace), ]; - $contentSize = count($content); - $content = [...$content, ...array_fill($contentSize - 1, $this->height - $contentSize - 2, '')]; // Subtract 2 for the border. - $this->setContent($content); + $this->setContent(array_pad($content, $this->height - 2, '')); $this->render(); } /** - * Colorize the stat. + * Formats a stat row so the current and preview columns stay aligned. + * + * @param string $label The stat label. + * @param int|null $currentStat The current effective stat. + * @param int|null $previewStat The preview stat after the pending change. + * @return string The formatted stat line. + */ + private function formatStatLine(string $label, ?int $currentStat, ?int $previewStat): string + { + return sprintf( + " %-10s %6s -> %6s %1s", + $label, + $this->formatStatValue($currentStat), + $this->formatStatValue($previewStat ?? $currentStat), + $this->getStatIndicator($currentStat, $previewStat) + ); + } + + /** + * Formats a stat value for the right-aligned preview columns. + * + * @param int|null $stat The stat value to format. + * @return string The formatted stat value. + */ + private function formatStatValue(?int $stat): string + { + if (! isset($stat)) { + return ''; + } + + return str_pad(number_format($stat), self::STAT_VALUE_WIDTH, ' ', STR_PAD_LEFT); + } + + /** + * Returns the visual indicator for a stat preview change. * - * @param int|null $currentStat The previous stat. - * @param int|null $previewStat The stat to colorize. - * @return string The colorized stat. + * @param int|null $currentStat The current stat. + * @param int|null $previewStat The preview stat. + * @return string The comparison indicator. */ - private function highlightStat(?int $currentStat, ?int $previewStat): string + private function getStatIndicator(?int $currentStat, ?int $previewStat): string { - $suffix = ''; if (isset($currentStat) && isset($previewStat)) { - $suffix = match(true) { + return match(true) { $currentStat < $previewStat => '↑', $currentStat > $previewStat => '↓', default => '', }; } - $value = $previewStat ?? ''; - return "{$value} {$suffix}"; + return ''; } -} \ No newline at end of file +} diff --git a/src/Core/Menu/EquipmentMenu/Windows/EquipmentAssignmentPanel.php b/src/Core/Menu/EquipmentMenu/Windows/EquipmentAssignmentPanel.php index f92cd28..ed578d6 100644 --- a/src/Core/Menu/EquipmentMenu/Windows/EquipmentAssignmentPanel.php +++ b/src/Core/Menu/EquipmentMenu/Windows/EquipmentAssignmentPanel.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\EquipmentSlot; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; @@ -83,7 +84,9 @@ public function updateContent(): void foreach ($this->slots as $index => $slot) { $prefix = $index === $this->activeSlotIndex ? '>' : ' '; - $content[] = sprintf(" %s %-20s %s", $prefix, "{$slot->name}:", "{$slot->equipment?->icon} {$slot->equipment?->name}"); + $slotName = TerminalText::padRight("{$slot->name}:", 20); + $equippedItem = trim("{$slot->equipment?->icon} {$slot->equipment?->name}"); + $content[] = " {$prefix} {$slotName} {$equippedItem}"; } $content = array_pad($content, $this->height - 2, ''); @@ -130,4 +133,4 @@ public function getSize(): Area { return new Area($this->width, $this->height); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/EquipmentMenu/Windows/EquipmentCommandPanel.php b/src/Core/Menu/EquipmentMenu/Windows/EquipmentCommandPanel.php index e088b97..179e04a 100644 --- a/src/Core/Menu/EquipmentMenu/Windows/EquipmentCommandPanel.php +++ b/src/Core/Menu/EquipmentMenu/Windows/EquipmentCommandPanel.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Menu\EquipmentMenu\EquipmentMenu; use Ichiloto\Engine\Core\Menu\MenuItem; use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; @@ -86,7 +87,7 @@ public function updateContent(): void /** @var MenuItem $item */ foreach ($this->menu->getItems() as $index => $item) { $prefix = $index === $this->menu->activeIndex ? '>' : ' '; - $content .= sprintf(" %s %-12s", $prefix, $item->getLabel()); + $content .= ' ' . $prefix . ' ' . TerminalText::padRight((string)$item, 12); } if (!is_iterable($content)) { @@ -96,4 +97,4 @@ public function updateContent(): void $this->setContent($content); $this->render(); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php b/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php index 556ed74..93918e6 100644 --- a/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php +++ b/src/Core/Menu/ItemMenu/Windows/ItemSelectionPanel.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Scenes\Game\States\ItemMenuState; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; @@ -166,7 +167,9 @@ public function updateContent(): void foreach ($this->items as $index => $item) { $prefix = $index === $this->activeIndex ? '>' : ' '; - $content[$index] = sprintf(" %s %-60s %2d", $prefix, $item->name, $item->quantity); + $itemName = TerminalText::padRight($item->name, 60); + $quantity = TerminalText::padLeft((string)$item->quantity, 2); + $content[$index] = " {$prefix} {$itemName} {$quantity}"; } $this->setContent($content); @@ -182,4 +185,4 @@ protected function updateInfoPanel(): void $this->state->infoPanel->setText($this->activeItem->description); } } -} \ No newline at end of file +} diff --git a/src/Core/Menu/Menu.php b/src/Core/Menu/Menu.php index f04e6ed..f2a3c25 100644 --- a/src/Core/Menu/Menu.php +++ b/src/Core/Menu/Menu.php @@ -12,7 +12,7 @@ use Ichiloto\Engine\Events\Interfaces\EventInterface; use Ichiloto\Engine\Events\Interfaces\ObserverInterface; use Ichiloto\Engine\Events\MenuEvent; -use Ichiloto\Engine\IO\Enumerations\Color; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; @@ -64,7 +64,7 @@ public function __construct( { $this->observers = new ItemList(ObserverInterface::class); $this->totalItems = $this->items->count(); - $this->cursor = substr($cursor, 0, 1); + $this->cursor = TerminalText::firstSymbol($cursor) ?: '>'; $this->activate(); $this->updateWindowContent(); } @@ -264,7 +264,7 @@ public function getCursor(): string */ public function setCursor(string $cursor): void { - $this->cursor = substr($cursor, 0, 1); + $this->cursor = TerminalText::firstSymbol($cursor) ?: '>'; } /** @@ -334,14 +334,13 @@ public function updateWindowContent(): void * @var MenuItemInterface $item */ foreach ($this->items as $itemIndex => $item) { - $color = $item->isDisabled() ? Color::BLUE->value : ''; $prefix = ' '; if ($itemIndex === $this->activeIndex) { $prefix = "$this->cursor "; } - $output = $prefix . $item->getLabel(); + $output = $prefix . $item; $content[] = $output; } @@ -352,4 +351,4 @@ public function updateWindowContent(): void $this->window?->setContent($content); $this->render(); } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php b/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php index ea8da8d..b23d921 100644 --- a/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php +++ b/src/Core/Menu/ShopMenu/Modes/PurchaseConfirmationMode.php @@ -5,6 +5,7 @@ use Exception; use Ichiloto\Engine\Entities\Inventory\InventoryItem; use Ichiloto\Engine\Entities\Party; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Util\Config\ProjectConfig; @@ -204,11 +205,14 @@ protected function decreaseQuantity(int $amount = 1): void */ public function updateWindowContent(): void { + $itemName = TerminalText::padRight($this->item->name ?? 'N/A', 45); + $quantity = TerminalText::padLeft((string)$this->quantity, 2); + $totalPrice = TerminalText::padLeft((string)$this->totalPrice, 48); $content = [ "", - sprintf(" %-45s x %2d", $this->item->name ?? 'N/A', $this->quantity), + " {$itemName} x {$quantity}", " -------------------------------------------------- ", - sprintf(" %48d %s", $this->totalPrice, $this->symbol), + " {$totalPrice} {$this->symbol}", ]; $content = array_pad($content, $this->state->mainPanel->contentHeight, ''); @@ -240,4 +244,4 @@ public function completeCheckout(): void $this->previousMode->updateItemsInPossession(); } } -} \ No newline at end of file +} diff --git a/src/Core/Menu/ShopMenu/Windows/ShopMainPanel.php b/src/Core/Menu/ShopMenu/Windows/ShopMainPanel.php index 83a37fe..2e64c05 100644 --- a/src/Core/Menu/ShopMenu/Windows/ShopMainPanel.php +++ b/src/Core/Menu/ShopMenu/Windows/ShopMainPanel.php @@ -5,6 +5,7 @@ use Ichiloto\Engine\Core\Menu\ShopMenu\ShopMenu; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; use Ichiloto\Engine\Util\Config\ProjectConfig; @@ -110,7 +111,9 @@ public function updateContent(): void foreach ($this->items as $index => $item) { $prefix = $index === $this->activeItemIndex ? '>' : ' '; $price = $item->price * $this->priceRate; - $content[] = sprintf(" %s %-36s %10s", $prefix, $item->name, "{$price} {$symbol}"); + $itemName = TerminalText::padRight($item->name, 36); + $priceText = TerminalText::padLeft("{$price} {$symbol}", 10); + $content[] = " {$prefix} {$itemName} {$priceText}"; } $content = array_pad($content, $this->contentHeight, ''); @@ -139,4 +142,4 @@ public function selectPrevious(): void $index = wrap($this->activeItemIndex - 1, 0, $this->totalItems - 1); $this->activeItemIndex = $index; } -} \ No newline at end of file +} diff --git a/src/Entities/Actions/SleepAction.php b/src/Entities/Actions/SleepAction.php index 09a4cb3..12afece 100644 --- a/src/Entities/Actions/SleepAction.php +++ b/src/Entities/Actions/SleepAction.php @@ -59,8 +59,8 @@ public function execute(ActionContextInterface $context): void $sleepTime = config(ProjectConfig::class, 'inn.sleep_time', self::SLEEP_TIME); $sleepInterval = intval((clamp($sleepTime, 1, 10) * 1000000) / $sleepAnimationFrameCount); - $leftMargin = (get_screen_width() / 2) - 2; - $topMargin = (get_screen_height() / 2) - 1; + $leftMargin = intdiv(get_screen_width(), 2) - 2; + $topMargin = intdiv(get_screen_height(), 2) - 1; for ($index = 0; $index < $sleepAnimationFrameCount; $index++) { Console::clear(); Console::write($sleepFrames[$index], $leftMargin, $topMargin); @@ -84,4 +84,4 @@ public function execute(ActionContextInterface $context): void $context->player->render(); } } -} \ No newline at end of file +} diff --git a/src/Entities/BattleGroup.php b/src/Entities/BattleGroup.php index 38d4c43..4fcc0f6 100644 --- a/src/Entities/BattleGroup.php +++ b/src/Entities/BattleGroup.php @@ -50,6 +50,7 @@ public function addMember(Battler $character): void */ public function isDefeated(): bool { - return array_all($this->members->toArray(), fn($member) => $member->isDefeated()); + return $this->members->count() > 0 && + array_all($this->members->toArray(), fn(Battler $member) => $member->isKnockedOut); } -} \ No newline at end of file +} diff --git a/src/Entities/Character.php b/src/Entities/Character.php index fd86913..1349a7a 100644 --- a/src/Entities/Character.php +++ b/src/Entities/Character.php @@ -260,13 +260,8 @@ public static function fromArray(array $data): self * @inheritDoc * @throws Exception If an error occurs while alerting the user. */ - public function equip(?Equipment $equipment): void + public function equip(Equipment $equipment): void { - if (is_null($equipment)) { - alert('No equipment to equip.'); - return; - } - if (! $this->canEquip($equipment) ) { alert(sprintf('%s cannot be equipped.', $equipment->name)); return; @@ -274,14 +269,39 @@ public function equip(?Equipment $equipment): void foreach ($this->equipment as $slot) { if ($slot->acceptsType === $equipment::class) { - $slot->equipment = $equipment; - $this->adjustStatTotals($equipment); - alert(sprintf("Equipped %s on %s", $equipment->name, $this->name)); + $this->equipInSlot($slot, $equipment); return; } } } + /** + * Equips an item into a specific slot. + * + * @param EquipmentSlot $slot The slot to equip into. + * @param Equipment $equipment The equipment to place in the slot. + * @return void + * @throws Exception If the equipment cannot be equipped. + */ + public function equipInSlot(EquipmentSlot $slot, Equipment $equipment): void + { + if (! $this->canEquip($equipment) || $slot->acceptsType !== $equipment::class) { + alert(sprintf('%s cannot be equipped.', $equipment->name)); + return; + } + + foreach ($this->equipment as $equipmentSlot) { + if ($equipmentSlot->name !== $slot->name) { + continue; + } + + $equipmentSlot->equipment = $equipment; + $this->adjustStatTotals(); + alert(sprintf("Equipped %s on %s", $equipment->name, $this->name)); + return; + } + } + /** * @inheritDoc */ @@ -290,7 +310,7 @@ public function unequip(EquipmentSlot $slot): void foreach ($this->equipment as $equipmentSlot) { if ($equipmentSlot->name === $slot->name) { $equipmentSlot->equipment = null; - $this->adjustStatTotals($equipmentSlot->equipment); + $this->adjustStatTotals(); return; } } @@ -403,16 +423,16 @@ public function optimizeEquipment(Inventory $inventory): void $equipmentSlot->equipment = $optimalEquipment; } + $this->adjustStatTotals(); alert('Equipment optimized!'); } /** * Adjusts the character's stat totals after equipping an item. * - * @param Equipment|null $equipment The equipment being equipped. * @return void */ - protected function adjustStatTotals(?Equipment $equipment = null): void + protected function adjustStatTotals(): void { $this->stats->totalHp = $this->totalHpCurve[$this->level] ?? 0; $this->stats->totalMp = $this->totalMpCurve[$this->level] ?? 0; @@ -424,17 +444,10 @@ protected function adjustStatTotals(?Equipment $equipment = null): void $this->stats->grace = $this->graceCurve[$this->level] ?? 0; $this->stats->speed = $this->speedCurve[$this->level] ?? 0; - if ($equipment) { - $this->stats->totalHp += ($equipment->parameterChanges->totalHp ?? 0); - $this->stats->totalMp += ($equipment->parameterChanges->totalMp ?? 0); - $this->stats->attack += ($equipment->parameterChanges->attack ?? 0); - $this->stats->defence += ($equipment->parameterChanges->defence ?? 0); - $this->stats->magicAttack += ($equipment->parameterChanges->magicAttack ?? 0); - $this->stats->magicDefence += ($equipment->parameterChanges->magicDefence ?? 0); - $this->stats->evasion += ($equipment->parameterChanges->evasion ?? 0); - $this->stats->grace += ($equipment->parameterChanges->grace ?? 0); - $this->stats->speed += ($equipment->parameterChanges->speed ?? 0); - } + // Re-apply the clamps after a level or equipment refresh. + $this->stats->currentHp = $this->stats->currentHp; + $this->stats->currentMp = $this->stats->currentMp; + $this->stats->currentAp = $this->stats->currentAp; } /** @@ -538,4 +551,4 @@ public function addExperience(int $exp): void $this->currentExp += $exp; $this->adjustStatTotals(); } -} \ No newline at end of file +} diff --git a/src/Entities/Party.php b/src/Entities/Party.php index 144a19c..004c3c5 100644 --- a/src/Entities/Party.php +++ b/src/Entities/Party.php @@ -5,6 +5,7 @@ use Assegai\Collections\ItemList; use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; use Ichiloto\Engine\Entities\Interfaces\InventoryItemInterface; +use Ichiloto\Engine\Entities\Inventory\Equipment; use Ichiloto\Engine\Entities\Inventory\Inventory; /** @@ -157,4 +158,42 @@ public function cannotAfford(int $cost): bool { return ! $this->canAfford($cost); } -} \ No newline at end of file + + /** + * Counts how many copies of an equipment item are currently worn by the party. + * + * @param Equipment $equipment The equipment to count. + * @return int The number of equipped copies. + */ + public function getEquippedEquipmentCount(Equipment $equipment): int + { + $count = 0; + + foreach ($this->members->toArray() as $member) { + assert($member instanceof Character); + + foreach ($member->equipment as $slot) { + if ($slot->equipment === null) { + continue; + } + + if ($slot->equipment::class === $equipment::class && $slot->equipment->name === $equipment->name) { + $count++; + } + } + } + + return $count; + } + + /** + * Returns the number of unequipped copies still available in the party inventory. + * + * @param Equipment $equipment The equipment to check. + * @return int The number of available copies. + */ + public function getAvailableEquipmentQuantity(Equipment $equipment): int + { + return max(0, $equipment->quantity - $this->getEquippedEquipmentCount($equipment)); + } +} diff --git a/src/Field/MapManager.php b/src/Field/MapManager.php index b4aec3e..a5f0af8 100644 --- a/src/Field/MapManager.php +++ b/src/Field/MapManager.php @@ -15,6 +15,7 @@ use Ichiloto\Engine\Exceptions\OutOfBounds; use Ichiloto\Engine\Exceptions\RequiredFieldException; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Util\Debug; @@ -33,7 +34,7 @@ class MapManager implements CanRenderAt */ protected static ?self $instance = null; /** - * @var string[] The tile map. + * @var array The tile map. */ protected array $tileMap = []; /** @@ -281,7 +282,7 @@ private function loadTileMap(string $filename, Player $player): void /** * Loads the collision map from a tile map. * - * @param string[] $tileMap The tile map. + * @param array $tileMap The tile map. * @return void * @throws NotFoundException */ @@ -294,7 +295,7 @@ private function loadCollisionMap(array $tileMap): void /** * Generates a collision map from a tile map. * - * @param string[] $tilemap The tile map. + * @param array $tilemap The tile map. * @param array $dictionary The dictionary that maps tile characters to collision types. * @return int[][] The collision map. */ @@ -312,8 +313,10 @@ public function generateCollisionMap( foreach ($tilemap as $row) { $collisionRow = []; - foreach (mb_str_split($row) as $tile) { - $cleanedTile = ASCII::to_ascii($tile); + $tiles = is_array($row) ? $row : TerminalText::visibleSymbols($row); + + foreach ($tiles as $tile) { + $cleanedTile = ASCII::to_ascii(TerminalText::stripAnsi($tile)); $collisionRow[] = $dictionary[$cleanedTile]->value ?? CollisionType::SOLID->value; } @@ -392,7 +395,7 @@ protected function loadMapEvents(array $events): void */ public function renderBackgroundTile(int $x, int $y): void { - $tile = $this->tileMap[$y][$x]; + $tile = $this->tileMap[$y][$x] ?? ' '; $screenSpacePosition = $this->camera->getScreenSpacePosition(new Vector2($x, $y)); $this->camera->draw($tile, $screenSpacePosition->x, $screenSpacePosition->y); } @@ -419,55 +422,69 @@ protected function getCollisionDictionary(): array public function scrollMap(Player $player, Vector2 $moveDirection): bool { $didScroll = false; - $halfScreenWidth = $this->camera->screen->getWidth() / 2; - $halfScreenHeight = $this->camera->screen->getHeight() / 2; - - if ($this->mapIsSmallerThanScreen($this->camera->screen)) { + $horizontalFocus = $this->camera->getHorizontalFocusPosition(); + $verticalFocus = $this->camera->getVerticalFocusPosition(); + $rightViewportPadding = $this->camera->screen->getWidth() - $horizontalFocus - 1; + $bottomViewportPadding = $this->camera->screen->getHeight() - $verticalFocus - 1; + $canScrollHorizontally = ! $this->screenIsWiderThanMap($this->camera->screen); + $canScrollVertically = ! $this->screenIsTallerThanMap($this->camera->screen); + $maxX = max(0, $this->mapWidth - $this->camera->screen->getWidth()); + $maxY = max(0, $this->mapHeight - $this->camera->screen->getHeight()); + + if (! $canScrollHorizontally && ! $canScrollVertically) { return false; } switch ($moveDirection) { case Vector2::left(): + if (! $canScrollHorizontally) { + break; + } $playerDistanceFromLeftScreenEdge = $player->position->x - $this->camera->screen->getLeft(); - if ($playerDistanceFromLeftScreenEdge < $halfScreenWidth) { - if (($player->position->x - $halfScreenWidth) > 0) { + if ($playerDistanceFromLeftScreenEdge < $horizontalFocus) { + if (($player->position->x - $horizontalFocus) > 0) { $newX = max(0, $this->camera->position->x - 1); - $newX = clamp($newX, 0, $this->camera->screen->getWidth()); - $this->camera->screen->setX(max(0, $newX)); + $this->camera->screen->setX(clamp($newX, 0, $maxX)); $didScroll = true; } } break; case Vector2::right(): - $playerDistanceFromRightScreenEdge = $this->camera->screen->getRight() - $player->position->x; - if ($playerDistanceFromRightScreenEdge < $halfScreenWidth) { - if (($player->position->x + $halfScreenWidth) < $this->mapWidth) { - $newX = min($this->mapWidth - $halfScreenWidth,$this->camera->position->x + 1); - $newX = clamp($newX, 0, $this->camera->screen->getWidth()); - $this->camera->screen->setX(min($this->mapWidth - $halfScreenWidth, $newX)); + if (! $canScrollHorizontally) { + break; + } + $playerDistanceFromRightScreenEdge = ($this->camera->screen->getRight() - 1) - $player->position->x; + if ($playerDistanceFromRightScreenEdge < $rightViewportPadding) { + if (($player->position->x + $rightViewportPadding) < $this->mapWidth - 1) { + $newX = min($maxX, $this->camera->position->x + 1); + $this->camera->screen->setX(clamp($newX, 0, $maxX)); $didScroll = true; } } break; case Vector2::up(): - $playerDistanceFromBottomScreenEdge = $player->position->y - $this->camera->screen->getTop(); - if ($playerDistanceFromBottomScreenEdge < $halfScreenHeight) { + if (! $canScrollVertically) { + break; + } + $playerDistanceFromTopScreenEdge = $player->position->y - $this->camera->screen->getTop(); + if ($playerDistanceFromTopScreenEdge < $verticalFocus) { $newY = max(0, $this->camera->position->y - 1); - $newY = clamp($newY, 0, $this->camera->screen->getHeight()); - $this->camera->screen->setY($newY); + $this->camera->screen->setY(clamp($newY, 0, $maxY)); $didScroll = true; } break; case Vector2::down(): - $playerDistanceFromBottomScreenEdge = $this->camera->screen->getBottom() - $player->position->y; - if ($playerDistanceFromBottomScreenEdge < $halfScreenHeight) { - if (($player->position->y + $halfScreenHeight) < $this->mapHeight) { - $newY = min($this->mapHeight - $halfScreenHeight, $this->camera->position->y + 1); - $newY = clamp($newY, 0, $this->camera->screen->getHeight()); - $this->camera->screen->setY($newY); + if (! $canScrollVertically) { + break; + } + $playerDistanceFromBottomScreenEdge = ($this->camera->screen->getBottom() - 1) - $player->position->y; + if ($playerDistanceFromBottomScreenEdge < $bottomViewportPadding) { + if (($player->position->y + $bottomViewportPadding) < $this->mapHeight - 1) { + $newY = min($maxY, $this->camera->position->y + 1); + $this->camera->screen->setY(clamp($newY, 0, $maxY)); $didScroll = true; } } @@ -485,7 +502,7 @@ public function scrollMap(Player $player, Vector2 $moveDirection): bool protected function calculateMapDimensions(): void { $this->mapHeight = count($this->tileMap); - $this->mapWidth = array_reduce($this->tileMap, fn($carry, $row) => max($carry, strlen($row)), 0); + $this->mapWidth = array_reduce($this->tileMap, fn($carry, $row) => max($carry, count($row)), 0); } /** @@ -544,8 +561,12 @@ public function readMapDataFromFile(string $filename): mixed throw new NotFoundException("File $filename does not return an array."); } - $this->tileMap = $map['tile_map'] ?? throw new InvalidArgumentException("tile_map not found in map array of $filename."); + $rawTileMap = $map['tile_map'] ?? throw new InvalidArgumentException("tile_map not found in map array of $filename."); + $this->tileMap = array_map( + static fn(string $row): array => TerminalText::visibleSymbols($row), + $rawTileMap + ); $this->camera->worldSpace = $this->tileMap; return $map; } -} \ No newline at end of file +} diff --git a/src/Field/Player.php b/src/Field/Player.php index 789c636..6ce0561 100644 --- a/src/Field/Player.php +++ b/src/Field/Player.php @@ -16,6 +16,7 @@ use Ichiloto\Engine\Events\Triggers\EventTriggerContext; use Ichiloto\Engine\Exceptions\NotFoundException; use Ichiloto\Engine\Exceptions\OutOfBounds; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; @@ -81,9 +82,7 @@ class Player extends GameObject */ public Vector2 $screenPosition { get { - $screenPosition = $this->position; - $screenPosition->subtract($this->scene->camera->position); - return $screenPosition; + return $this->scene->camera->getScreenSpacePosition($this->position); } } @@ -114,6 +113,8 @@ public function __construct( $sprite ); $this->heading = $heading; + $this->canShowLocationHUDWindow = config(ProjectConfig::class, 'ui.hud.location', false); + $this->events = new ItemList(EventTrigger::class); } /** @@ -122,14 +123,13 @@ public function __construct( #[Override] public function activate(): void { - parent::activate(); $this->canShowLocationHUDWindow = config(ProjectConfig::class, 'ui.hud.location', false); if (!$this->canShowLocationHUDWindow) { $this->getLocationHUDWindow()->erase(); } - $this->events = new ItemList(EventTrigger::class); + parent::activate(); } /** @@ -356,16 +356,23 @@ public function render(): void public function renderPlayer(?Vector2 $offset = null): void { - $x = $this->position->x - ($offset?->x ?? 0); - $y = $this->position->y - ($offset?->y ?? 0); + $worldPosition = new Vector2( + $this->position->x - ($offset?->x ?? 0), + $this->position->y - ($offset?->y ?? 0) + ); + $screenPosition = $this->scene->camera->getScreenSpacePosition($worldPosition); for ($row = $this->shape->getY(); $row < $this->shape->getY() + $this->shape->getHeight(); $row++) { - $output = substr($this->sprite[$row], $this->shape->getX(), $this->shape->getWidth()); - $this->scene->camera->draw($output, $x, $y + $row); + $output = TerminalText::sliceSymbols($this->sprite[$row], $this->shape->getX(), $this->shape->getWidth()); + $this->scene->camera->draw($output, $screenPosition->x, $screenPosition->y + $row); } if ($this->canAct) { - $this->scene->camera->draw($this->actionSprite, $x, clamp($y - 1, 1, get_screen_height())); + $this->scene->camera->draw( + $this->actionSprite, + $screenPosition->x, + clamp($screenPosition->y - 1, 1, get_screen_height()) + ); } } @@ -411,4 +418,4 @@ public function interact(): void $this->position )); } -} \ No newline at end of file +} diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 4bda3de..fb9c33f 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -9,6 +9,7 @@ use Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition; use RuntimeException; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Terminal; /** * Represents the console. @@ -61,12 +62,28 @@ public static function init(Game $game, array $options = [ 'height' => DEFAULT_SCREEN_HEIGHT, ]): void { + $availableSize = self::getAvailableSize(); + self::$game = $game; - self::clear(); Console::cursor()->disableBlinking(); - self::$width = $options['width'] ?? get_screen_width(); - self::$height = $options['height'] ?? get_screen_height(); + self::$width = intval($options['width'] ?? $availableSize['width']); + self::$height = intval($options['height'] ?? $availableSize['height']); self::$output = new ConsoleOutput(); + self::clear(); + } + + /** + * Returns the currently available terminal size. + * + * @return array{width: int, height: int} The terminal width and height. + */ + public static function getAvailableSize(): array + { + $terminal = new Terminal(); + $width = max(1, $terminal->getWidth() ?: DEFAULT_SCREEN_WIDTH); + $height = max(1, $terminal->getHeight() ?: DEFAULT_SCREEN_HEIGHT); + + return ['width' => $width, 'height' => $height]; } /** @@ -174,9 +191,28 @@ public static function setTerminalName(string $name): void */ public static function setTerminalSize(int $width, int $height): void { + self::$width = $width; + self::$height = $height; echo "\033[8;$height;{$width}t"; } + /** + * Synchronizes the console's internal dimensions with the terminal. + * + * This updates the backing buffer without forcing the terminal emulator to + * resize, which is useful when the user manually changes the window size. + * + * @param int $width The current terminal width. + * @param int $height The current terminal height. + * @return void + */ + public static function syncDimensions(int $width, int $height): void + { + self::$width = max(1, $width); + self::$height = max(1, $height); + self::$buffer = self::getEmptyBuffer(); + } + /** * Saves the terminal settings. * @@ -201,34 +237,37 @@ public static function restoreTerminalSettings(): void * Writes text to the console at the specified position. * * @param iterable|string $message The text to write. - * @param int $x The x position. - * @param int $y The y position. + * @param int|float $x The x position. + * @param int|float $y The y position. * @return void */ - public static function write(iterable|string $message, int $x, int $y): void + public static function write(iterable|string $message, int|float $x, int|float $y): void { $textRows = is_string($message) ? explode("\n", $message) : $message; - $cursor = self::cursor(); + $x = (int)floor($x); + $y = (int)floor($y); + $x = max(0, min($x, max(0, self::$width - 1))); - $output = ''; foreach ($textRows as $rowIndex => $text) { $currentBufferRow = $y + $rowIndex; + if ($currentBufferRow < 0 || $currentBufferRow >= self::$height) { + continue; + } + if (!isset(self::$buffer[$currentBufferRow])) { - self::$buffer[$currentBufferRow] = str_repeat(' ', get_screen_width()); + self::$buffer[$currentBufferRow] = str_repeat(' ', self::$width); } - self::$buffer[$currentBufferRow] = substr_replace(self::$buffer[$currentBufferRow], $text, $x, mb_strlen($text)); - $output .= self::$buffer[$currentBufferRow] . "\n"; - } + $bufferSymbols = TerminalText::visibleSymbols(self::$buffer[$currentBufferRow]); + $bufferSymbols = array_pad($bufferSymbols, self::$width, ' '); + $text = TerminalText::sliceSymbols((string)$text, 0, max(0, self::$width - $x)); + $textSymbols = TerminalText::visibleSymbols($text); - $row = clamp($y + 1, 1, get_screen_height()); - $column = 0; - $cursor->moveTo($column, $row); - if (self::$output) { - self::$output->write($output); - } else { - echo $output; + array_splice($bufferSymbols, $x, count($textSymbols), $textSymbols); + $bufferSymbols = array_pad(array_slice($bufferSymbols, 0, self::$width), self::$width, ' '); + self::$buffer[$currentBufferRow] = implode('', $bufferSymbols); + self::writeBufferRow($currentBufferRow); } } @@ -267,8 +306,10 @@ public static function charAt(int $x, int $y): string return ''; } - $char = substr(self::$buffer[$y], $x, 1); - return ord($char) === 0 ? ' ' : $char; + $symbols = TerminalText::visibleSymbols(self::$buffer[$y] ?? ''); + $char = TerminalText::stripAnsi($symbols[$x] ?? ' '); + + return $char === '' ? ' ' : $char; } /** @@ -278,7 +319,32 @@ public static function charAt(int $x, int $y): string */ private static function getEmptyBuffer(): array { - return array_fill(0, get_screen_height(), str_repeat(' ', get_screen_width())); + return array_fill(0, self::$height, str_repeat(' ', self::$width)); + } + + /** + * Flushes a single buffered row to the terminal without adding a trailing newline. + * + * Avoiding a final line-feed prevents full-screen renders from triggering + * terminal scrolling when the last visible row is repainted. + * + * @param int $row The zero-based buffer row to flush. + * @return void + */ + private static function writeBufferRow(int $row): void + { + if (!isset(self::$buffer[$row])) { + return; + } + + self::cursor()->moveTo(1, $row + 1); + + if (self::$output) { + self::$output->write(self::$buffer[$row]); + return; + } + + echo self::$buffer[$row]; } /** @@ -395,4 +461,4 @@ public static function getHeight(): int { return self::$height; } -} \ No newline at end of file +} diff --git a/src/IO/Console/Cursor.php b/src/IO/Console/Cursor.php index 9514f53..4b192df 100644 --- a/src/IO/Console/Cursor.php +++ b/src/IO/Console/Cursor.php @@ -58,13 +58,19 @@ public function show(): void /** * Moves the cursor to the specified coordinates. * - * @param int $x The x coordinate. - * @param int $y The y coordinate. + * Sub-cell values are floored so centered layout calculations can safely + * pass intermediate float positions without crashing the terminal renderer. + * + * @param int|float $x The x coordinate. + * @param int|float $y The y coordinate. * @return void * @throws InvalidArgumentException Thrown if the x or y coordinate is less than 0. */ - public function moveTo(int $x, int $y): void + public function moveTo(int|float $x, int|float $y): void { + $x = (int)floor($x); + $y = (int)floor($y); + if ($x < 0 || $y < 0) { throw new InvalidArgumentException('The x and y coordinates must be greater than or equal to 0.'); } @@ -187,4 +193,4 @@ public function disableBlinking(): void { echo "\033[?12l"; } -} \ No newline at end of file +} diff --git a/src/IO/Console/TerminalText.php b/src/IO/Console/TerminalText.php new file mode 100644 index 0000000..2045723 --- /dev/null +++ b/src/IO/Console/TerminalText.php @@ -0,0 +1,327 @@ + or . + */ + private const string FORMATTER_TAG_PATTERN = '/<\/?[-\w=;#,?]+>/'; + /** + * @var OutputFormatter|null Shared formatter used to normalize style tags. + */ + private static ?OutputFormatter $formatter = null; + + /** + * TerminalText constructor. + */ + private function __construct() + { + } + + /** + * Removes ANSI control codes from the given text. + * + * @param string $text The text to clean. + * @return string The visible text. + */ + public static function stripAnsi(string $text): string + { + $text = self::normalizeStyles($text); + return preg_replace(self::ANSI_PATTERN, '', $text) ?? $text; + } + + /** + * Returns the first visible symbol in the text. + * + * @param string $text The text to inspect. + * @return string The first visible symbol, or an empty string if none exists. + */ + public static function firstSymbol(string $text): string + { + return self::visibleSymbols($text)[0] ?? ''; + } + + /** + * Returns the number of visible symbols in the text. + * + * This is useful for world-space slicing where map coordinates are based on + * logical tiles rather than terminal cell width. + * + * @param string $text The text to measure. + * @return int The number of visible symbols. + */ + public static function symbolCount(string $text): int + { + return count(self::visibleSymbols($text)); + } + + /** + * Returns the display width, in terminal cells, for the given text. + * + * @param string $text The text to measure. + * @return int The display width. + */ + public static function displayWidth(string $text): int + { + $width = 0; + + foreach (self::visibleSymbols($text) as $symbol) { + $width += self::getSymbolWidth($symbol); + } + + return $width; + } + + /** + * Splits text into ANSI-safe visible symbols. + * + * Each returned symbol preserves any currently active ANSI style so that + * slices can be safely re-joined without losing coloring. + * + * @param string $text The text to split. + * @return string[] The visible symbols. + */ + public static function visibleSymbols(string $text): array + { + if ($text === '') { + return []; + } + + $text = self::normalizeStyles($text); + + $symbols = []; + $activeAnsi = ''; + preg_match_all('/\x1B\[[0-9;?]*[ -\/]*[@-~]|\X/u', $text, $matches); + + foreach ($matches[0] ?? [] as $token) { + if (preg_match(self::ANSI_PATTERN, $token) === 1) { + if (self::isResetSequence($token)) { + $activeAnsi = ''; + } else { + $activeAnsi .= $token; + } + continue; + } + + if ($token === '') { + continue; + } + + $symbols[] = $activeAnsi !== '' + ? $activeAnsi . $token . Color::RESET->value + : $token; + } + + return $symbols; + } + + /** + * Returns a symbol-based slice of the text. + * + * @param string $text The text to slice. + * @param int $start The starting symbol index. + * @param int|null $length The number of symbols to include. + * @return string The sliced text. + */ + public static function sliceSymbols(string $text, int $start, ?int $length = null): string + { + $symbols = self::visibleSymbols($text); + $slice = array_slice($symbols, max(0, $start), $length); + + return implode('', $slice); + } + + /** + * Truncates text to the requested display width. + * + * @param string $text The text to truncate. + * @param int $width The maximum display width. + * @return string The truncated text. + */ + public static function truncateToWidth(string $text, int $width): string + { + if ($width <= 0 || $text === '') { + return ''; + } + + $visibleWidth = 0; + $output = []; + + foreach (self::visibleSymbols($text) as $symbol) { + $symbolWidth = self::getSymbolWidth($symbol); + + if ($visibleWidth + $symbolWidth > $width) { + break; + } + + $output[] = $symbol; + $visibleWidth += $symbolWidth; + } + + return implode('', $output); + } + + /** + * Right-pads text to the requested display width. + * + * @param string $text The text to pad. + * @param int $width The target width. + * @return string The padded text. + */ + public static function padRight(string $text, int $width): string + { + $text = self::truncateToWidth($text, $width); + $padding = max(0, $width - self::displayWidth($text)); + + return $text . str_repeat(' ', $padding); + } + + /** + * Left-pads text to the requested display width. + * + * @param string $text The text to pad. + * @param int $width The target width. + * @return string The padded text. + */ + public static function padLeft(string $text, int $width): string + { + $text = self::truncateToWidth($text, $width); + $padding = max(0, $width - self::displayWidth($text)); + + return str_repeat(' ', $padding) . $text; + } + + /** + * Centers text within the requested display width. + * + * @param string $text The text to pad. + * @param int $width The target width. + * @return string The padded text. + */ + public static function padCenter(string $text, int $width): string + { + $text = self::truncateToWidth($text, $width); + $padding = max(0, $width - self::displayWidth($text)); + $leftPadding = intdiv($padding, 2); + $rightPadding = $padding - $leftPadding; + + return str_repeat(' ', $leftPadding) . $text . str_repeat(' ', $rightPadding); + } + + /** + * Fits text to the target width using the requested alignment. + * + * @param string $text The text to fit. + * @param int $width The target width. + * @param string $alignment The alignment: left, center, or right. + * @return string The fitted text. + */ + public static function fit(string $text, int $width, string $alignment = 'left'): string + { + return match ($alignment) { + 'right' => self::padLeft($text, $width), + 'center' => self::padCenter($text, $width), + default => self::padRight($text, $width), + }; + } + + /** + * Measures the display width of a single visible symbol. + * + * @param string $symbol The symbol to measure. + * @return int The display width in terminal cells. + */ + private static function getSymbolWidth(string $symbol): int + { + $symbol = self::stripAnsi($symbol); + + if ($symbol === '') { + return 0; + } + + if (preg_match('/\x{200D}/u', $symbol) === 1 || preg_match('/\p{Extended_Pictographic}/u', $symbol) === 1) { + return 2; + } + + $baseSymbol = preg_replace('/[\p{Mn}\x{200D}\x{FE0E}\x{FE0F}]/u', '', $symbol) ?? $symbol; + + if ($baseSymbol === '') { + return 0; + } + + return max(1, min(2, mb_strwidth($baseSymbol, 'UTF-8'))); + } + + /** + * Converts supported Symfony formatter tags to ANSI codes. + * + * @param string $text The text to normalize. + * @return string The normalized text. + */ + private static function normalizeStyles(string $text): string + { + if ($text === '' || preg_match(self::FORMATTER_TAG_PATTERN, $text) !== 1) { + return $text; + } + + try { + return self::getFormatter()->format($text); + } catch (Throwable) { + return $text; + } + } + + /** + * Returns the shared Symfony output formatter instance. + * + * @return OutputFormatter The formatter. + */ + private static function getFormatter(): OutputFormatter + { + return self::$formatter ??= new OutputFormatter(true); + } + + /** + * Determines whether an ANSI sequence resets the active style state. + * + * @param string $ansi The ANSI sequence. + * @return bool True if the sequence resets formatting. + */ + private static function isResetSequence(string $ansi): bool + { + if (!preg_match('/\x1B\[([0-9;]*)m/', $ansi, $matches)) { + return false; + } + + $parameters = $matches[1] === '' + ? ['0'] + : array_filter(explode(';', $matches[1]), static fn(string $value): bool => $value !== ''); + + foreach ($parameters as $parameter) { + if (in_array((int)$parameter, [0, 22, 23, 24, 25, 27, 28, 29, 39, 49, 54, 55, 59], true)) { + return true; + } + } + + return false; + } +} diff --git a/src/IO/Enumerations/KeyCode.php b/src/IO/Enumerations/KeyCode.php index ff962ec..b915589 100644 --- a/src/IO/Enumerations/KeyCode.php +++ b/src/IO/Enumerations/KeyCode.php @@ -12,6 +12,7 @@ enum KeyCode: string case ENTER = 'enter'; case SPACE = 'space'; case TAB = 'tab'; + case SHIFT_TAB = 'shift_tab'; case BACKSPACE = 'backspace'; case ESCAPE = 'escape'; case DELETE = 'delete'; diff --git a/src/IO/InputManager.php b/src/IO/InputManager.php index 932b8bd..f1b4c64 100644 --- a/src/IO/InputManager.php +++ b/src/IO/InputManager.php @@ -232,6 +232,7 @@ private static function getKey(?string $keyPress): string "\033[B" => KeyCode::DOWN->value, "\033[C" => KeyCode::RIGHT->value, "\033[D" => KeyCode::LEFT->value, + "\033[Z" => KeyCode::SHIFT_TAB->value, "\n" => KeyCode::ENTER->value, " " => KeyCode::SPACE->value, "\010", @@ -277,4 +278,4 @@ public static function enableEcho(): void echo "\033[?12l"; system('stty -cbreak echo'); } -} \ No newline at end of file +} diff --git a/src/Rendering/Camera.php b/src/Rendering/Camera.php index 8c1bfd5..7266bb1 100644 --- a/src/Rendering/Camera.php +++ b/src/Rendering/Camera.php @@ -12,6 +12,7 @@ use Ichiloto\Engine\Exceptions\NotImplementedException; use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; use Ichiloto\Engine\Util\Debug; use Symfony\Component\Console\Output\ConsoleOutput; @@ -75,7 +76,11 @@ class Camera implements CanStart, CanResume, CanRender, CanUpdate $this->worldSpace = $value; $this->worldSpaceHeight = count($value); $this->worldSpaceWidth = array_reduce($value, function ($carry, $item) { - return max($carry, strlen($item)); + if (is_array($item)) { + return max($carry, count($item)); + } + + return max($carry, TerminalText::symbolCount((string)$item)); }, 0); } } @@ -140,12 +145,19 @@ public function stop(): void */ public function renderMap(): void { - for ($row = 0; $row < $this->screen->getHeight(); $row++) { + $renderOffset = $this->getRenderOffset(); + $visibleWidth = $this->getVisibleWorldWidth(); + $visibleHeight = $this->getVisibleWorldHeight(); + + for ($row = 0; $row < $visibleHeight; $row++) { $worldSpaceY = $this->position->y + $row; - $content = substr($this->worldSpace[$worldSpaceY] ?? str_repeat(' ', $this->width), $this->position->x, $this->width); + $worldRow = $this->worldSpace[$worldSpaceY] ?? array_fill(0, $visibleWidth, ' '); + $content = is_array($worldRow) + ? implode('', array_slice($worldRow, $this->position->x, $visibleWidth)) + : TerminalText::sliceSymbols((string)$worldRow, $this->position->x, $visibleWidth); + $content = TerminalText::padRight($content, $visibleWidth); - $screenSpaceY = $this->getScreenSpacePosition(new Vector2(0, $worldSpaceY))->y; - $this->draw($content, $this->position->x, $screenSpaceY); + $this->draw($content, $renderOffset->x, $renderOffset->y + $row); } } @@ -213,7 +225,7 @@ public function canSee(GameObject $gameObject): bool return false; } - if ($gameObject->position->x > $this->position->x + $this->width) { + if ($gameObject->position->x > $this->position->x + $this->width - 1) { return false; } @@ -221,7 +233,7 @@ public function canSee(GameObject $gameObject): bool return false; } - if ($gameObject->position->y > $this->position->y + $this->height) { + if ($gameObject->position->y > $this->position->y + $this->height - 1) { return false; } @@ -244,10 +256,10 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void $buffer = []; foreach ($content as $index => $line) { - if ($index > $this->screen->getHeight()) { + if ($index >= $this->screen->getHeight()) { break; } - $buffer[] = mb_substr($line, 0, $this->screen->getWidth()); + $buffer[] = TerminalText::truncateToWidth((string)$line, $this->screen->getWidth()); } $content = $buffer; @@ -255,16 +267,13 @@ public function draw(iterable|string $content, int $x = 0, int $y = 0): void if (is_iterable($content)) { foreach ($content as $index => $line) { $row = $y + $index; - $row = clamp($row, 0, $this->screen->getHeight()); - $column = $this->screen->getX() + $x; - $column = clamp($column, 0, $this->screen->getWidth()); -// Console::cursor()->moveTo($this->screen->getX() + $x, $this->screen->getY() + $y + $row); -// $this->output->write($line); + $row = clamp($row, 0, max(0, $this->screen->getHeight() - 1)); + $column = clamp($x, 0, max(0, $this->screen->getWidth() - 1)); Console::write($line, $column, $row); } } else { - $row = clamp($y, 0, $this->screen->getHeight()); - $column = clamp($x, 0, $this->screen->getWidth()); + $row = clamp($y, 0, max(0, $this->screen->getHeight() - 1)); + $column = clamp($x, 0, max(0, $this->screen->getWidth() - 1)); Console::write($content, $column, $row); } } @@ -312,8 +321,9 @@ public function moveTo(int $x, int $y): void */ public function getScreenSpacePosition(Vector2 $worldSpacePosition): Vector2 { - $screenSpaceX = $worldSpacePosition->x - $this->position->x; - $screenSpaceY = $worldSpacePosition->y - $this->position->y; + $renderOffset = $this->getRenderOffset(); + $screenSpaceX = $worldSpacePosition->x - $this->position->x + $renderOffset->x; + $screenSpaceY = $worldSpacePosition->y - $this->position->y + $renderOffset->y; return new Vector2($screenSpaceX, $screenSpaceY); } @@ -325,7 +335,12 @@ public function getScreenSpacePosition(Vector2 $worldSpacePosition): Vector2 */ public function getWorldSpacePosition(Vector2 $screenSpacePosition): Vector2 { - return Vector2::sum($screenSpacePosition, $this->position); + $renderOffset = $this->getRenderOffset(); + + return new Vector2( + $screenSpacePosition->x - $renderOffset->x + $this->position->x, + $screenSpacePosition->y - $renderOffset->y + $this->position->y, + ); } /** @@ -351,20 +366,99 @@ public function resetPosition(Player $player): void { $x = 0; $y = 0; - - $playerScreenPosition = $this->getScreenSpacePosition($player->position); + $maxX = max(0, $this->worldSpaceWidth - $this->screen->getWidth()); + $maxY = max(0, $this->worldSpaceHeight - $this->screen->getHeight()); if ($this->worldSpaceWidth > $this->screen->getWidth()) { - $halfWidth = $this->screen->getWidth() / 2; - $x = clamp($playerScreenPosition->x - $halfWidth, 0, $this->screen->getWidth() - $halfWidth); + $x = clamp(intval($player->position->x) - $this->getHorizontalFocusPosition(), 0, $maxX); } if ($this->worldSpaceHeight > $this->screen->getHeight()) { - $halfHeight = $this->screen->getHeight() / 2; - $y = clamp($playerScreenPosition->y - $halfHeight, 0, $this->screen->getHeight() - $halfHeight); + $y = clamp(intval($player->position->y) - $this->getVerticalFocusPosition(), 0, $maxY); } $this->screen->setX($x); $this->screen->setY($y); } -} \ No newline at end of file + + /** + * Resizes the camera viewport to match the current screen size. + * + * @param int $width The new viewport width. + * @param int $height The new viewport height. + * @return void + */ + public function resizeViewport(int $width, int $height): void + { + $this->width = max(1, $width); + $this->height = max(1, $height); + $this->screen->setWidth($this->width); + $this->screen->setHeight($this->height); + + $maxX = max(0, $this->worldSpaceWidth - $this->screen->getWidth()); + $maxY = max(0, $this->worldSpaceHeight - $this->screen->getHeight()); + + $this->screen->setX(clamp($this->screen->getX(), 0, $maxX)); + $this->screen->setY(clamp($this->screen->getY(), 0, $maxY)); + } + + /** + * Returns the horizontal focus column used to keep the player centered. + * + * @return int The focus column. + */ + public function getHorizontalFocusPosition(): int + { + return intdiv(max(0, $this->screen->getWidth() - 1), 2); + } + + /** + * Returns the vertical focus row used to keep the player centered. + * + * @return int The focus row. + */ + public function getVerticalFocusPosition(): int + { + return intdiv(max(0, $this->screen->getHeight() - 1), 2); + } + + /** + * Returns the viewport render offset used to center smaller maps. + * + * The camera position remains in world space so that scrolling logic does + * not change. Only the on-screen render origin is adjusted. + * + * @return Vector2 The render offset. + */ + protected function getRenderOffset(): Vector2 + { + $x = $this->worldSpaceWidth < $this->screen->getWidth() + ? intdiv($this->screen->getWidth() - $this->worldSpaceWidth, 2) + : 0; + $y = $this->worldSpaceHeight < $this->screen->getHeight() + ? intdiv($this->screen->getHeight() - $this->worldSpaceHeight, 2) + : 0; + + return new Vector2(max(0, $x), max(0, $y)); + } + + /** + * Returns the width of the world currently visible in the viewport. + * + * @return int The visible world width. + */ + protected function getVisibleWorldWidth(): int + { + return min($this->screen->getWidth(), max(0, $this->worldSpaceWidth - $this->position->x)); + } + + /** + * Returns the height of the world currently visible in the viewport. + * + * @return int The visible world height. + */ + protected function getVisibleWorldHeight(): int + { + return min($this->screen->getHeight(), max(0, $this->worldSpaceHeight - $this->position->y)); + } +} diff --git a/src/Scenes/AbstractScene.php b/src/Scenes/AbstractScene.php index c7a0e11..a13e2fa 100644 --- a/src/Scenes/AbstractScene.php +++ b/src/Scenes/AbstractScene.php @@ -213,6 +213,18 @@ public function renderBackgroundTile(int $x, int $y): void // Do nothing. This method is meant to be overridden. } + /** + * Updates the scene layout after the terminal size changes. + * + * @param int $width The new terminal width. + * @param int $height The new terminal height. + * @return void + */ + public function onScreenResize(int $width, int $height): void + { + $this->camera->resizeViewport($width, $height); + } + /** * Initialize event handlers. * @@ -271,4 +283,4 @@ protected function deregisterEventHandlers(): void $this->eventManager->removeEventListener(EventType::MODAL, $this->modalEventHandler); $this->eventManager->removeEventListener(EventType::NOTIFICATION, $this->notificationEventHandler); } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/BattleScene.php b/src/Scenes/Battle/BattleScene.php index 65b1ea3..07ddcb1 100644 --- a/src/Scenes/Battle/BattleScene.php +++ b/src/Scenes/Battle/BattleScene.php @@ -2,7 +2,9 @@ namespace Ichiloto\Engine\Scenes\Battle; +use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Battle\UI\BattleScreen; +use Ichiloto\Engine\Battle\UI\BattleResultWindow; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\Scenes\AbstractScene; @@ -85,6 +87,18 @@ class BattleScene extends AbstractScene * @var BattleScreen|null The battle screen of the scene. */ public ?BattleScreen $ui = null; + /** + * @var BattleResult|null The outcome of the current battle. + */ + public ?BattleResult $result = null; + /** + * @var BattleResultWindow|null The result window shown at the end of battle. + */ + public ?BattleResultWindow $resultWindow = null; + /** + * @var bool Whether the battle should transition to the game over scene. + */ + public bool $shouldLoadGameOver = false; /** * Sets the state of the scene. @@ -112,6 +126,9 @@ public function configure(SceneConfigurationInterface $config): void $this->uiManager->locationHUDWindow->deactivate(); $this->config = $config; + $this->result = null; + $this->resultWindow = null; + $this->shouldLoadGameOver = false; $this->initializeBattleSceneStates(); $this->setState($this->startState); } @@ -140,4 +157,4 @@ protected function initializeBattleSceneStates(): void $this->startState = new BattleStartState($this->sceneStateContext); $this->victoryState = new BattleVictoryState($this->sceneStateContext); } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/States/BattleDefeatState.php b/src/Scenes/Battle/States/BattleDefeatState.php index 179f227..7812a25 100644 --- a/src/Scenes/Battle/States/BattleDefeatState.php +++ b/src/Scenes/Battle/States/BattleDefeatState.php @@ -2,17 +2,37 @@ namespace Ichiloto\Engine\Scenes\Battle\States; -use Ichiloto\Engine\Scenes\Battle\States\BattleSceneState; +use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Scenes\SceneStateContext; +use RuntimeException; class BattleDefeatState extends BattleSceneState { + /** + * @inheritDoc + */ + public function enter(): void + { + $this->ui->hideControls(); + $this->scene->resultWindow?->display($this->scene->result ?? throw new RuntimeException('Battle result is not set.')); + } /** * @inheritDoc */ public function execute(?SceneStateContext $context = null): void { - // TODO: Implement execute() method. + if (Input::isButtonDown('action')) { + $this->scene->shouldLoadGameOver = true; + $this->setState($this->scene->endState); + } + } + + /** + * @inheritDoc + */ + public function exit(): void + { + $this->scene->resultWindow?->erase(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/States/BattleEndState.php b/src/Scenes/Battle/States/BattleEndState.php index 35bbbd8..8e099a4 100644 --- a/src/Scenes/Battle/States/BattleEndState.php +++ b/src/Scenes/Battle/States/BattleEndState.php @@ -2,21 +2,31 @@ namespace Ichiloto\Engine\Scenes\Battle\States; +use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Scenes\SceneStateContext; class BattleEndState extends BattleSceneState { - - public function execute(?SceneStateContext $context = null): void - { - // TODO: Implement execute() method. - } - /** * @inheritDoc */ - public function exit(): void + public function enter(): void { + $this->scene->resultWindow?->erase(); + $this->scene->ui?->erase(); $this->engine->stop(); + Console::clear(); + + if ($this->scene->shouldLoadGameOver) { + $this->scene->getGame()->sceneManager->loadGameOverScene(); + return; + } + + $this->scene->getGame()->sceneManager->returnFromBattleScene(); + } + + public function execute(?SceneStateContext $context = null): void + { + // Do nothing. Transition happens when the state is entered. } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/States/BattleStartState.php b/src/Scenes/Battle/States/BattleStartState.php index b068020..c10ff84 100644 --- a/src/Scenes/Battle/States/BattleStartState.php +++ b/src/Scenes/Battle/States/BattleStartState.php @@ -2,6 +2,7 @@ namespace Ichiloto\Engine\Scenes\Battle\States; +use Ichiloto\Engine\Battle\UI\BattleResultWindow; use Ichiloto\Engine\Battle\UI\BattleScreen; use Ichiloto\Engine\Core\Time; use Ichiloto\Engine\IO\Console\Console; @@ -45,6 +46,7 @@ class BattleStartState extends BattleSceneState public function enter(): void { $this->scene->ui = new BattleScreen($this->scene); + $this->scene->resultWindow = new BattleResultWindow($this->scene->ui); $this->cleanSlate = array_fill(0, $this->scene->ui->screenDimensions->getHeight(), str_repeat(' ', $this->scene->ui->screenDimensions->getWidth())); Console::clear(); $this->startTheIntroAnimation(); @@ -146,4 +148,4 @@ protected function playIntroAnimation(): void usleep($this->sleepTime); } -} \ No newline at end of file +} diff --git a/src/Scenes/Battle/States/BattleVictoryState.php b/src/Scenes/Battle/States/BattleVictoryState.php index 5079408..42f1e30 100644 --- a/src/Scenes/Battle/States/BattleVictoryState.php +++ b/src/Scenes/Battle/States/BattleVictoryState.php @@ -2,17 +2,37 @@ namespace Ichiloto\Engine\Scenes\Battle\States; -use Ichiloto\Engine\Scenes\Battle\States\BattleSceneState; +use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Scenes\SceneStateContext; +use RuntimeException; class BattleVictoryState extends BattleSceneState { + /** + * @inheritDoc + */ + public function enter(): void + { + $this->ui->hideControls(); + $this->scene->resultWindow?->display($this->scene->result ?? throw new RuntimeException('Battle result is not set.')); + } /** * @inheritDoc */ public function execute(?SceneStateContext $context = null): void { - // TODO: Implement execute() method. + if (Input::isButtonDown('action')) { + $this->scene->shouldLoadGameOver = false; + $this->setState($this->scene->endState); + } + } + + /** + * @inheritDoc + */ + public function exit(): void + { + $this->scene->resultWindow?->erase(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/GameScene.php b/src/Scenes/Game/GameScene.php index 6d1fc63..36c4764 100644 --- a/src/Scenes/Game/GameScene.php +++ b/src/Scenes/Game/GameScene.php @@ -10,6 +10,7 @@ use Ichiloto\Engine\Field\Location; use Ichiloto\Engine\Field\MapManager; use Ichiloto\Engine\Field\Player; +use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Scenes\AbstractScene; use Ichiloto\Engine\Scenes\Game\States\CutsceneState; use Ichiloto\Engine\Scenes\Game\States\DialogueState; @@ -148,14 +149,12 @@ public function configure(SceneConfigurationInterface $config): void $this->config->playerSprite, $this->config->playerHeading ); - $this->player->activate(); $this->party = $this->config->party; $this->loadMap("{$this->config->mapId}.php", $this->player); - $this->setState($this->fieldState); - usleep(400); + $this->player->activate(); $this->locationHUDWindow->updateDetails($this->player->position, $this->player->heading); - $this->locationHUDWindow->render(); + $this->setState($this->fieldState); } /** @@ -253,4 +252,32 @@ public function initializeGameSceneStates(): void $this->overworldState = new OverworldState($this->sceneStateContext); $this->shopState = new ShopState($this->sceneStateContext); } -} \ No newline at end of file + + /** + * Updates the active game-scene layout after the terminal size changes. + * + * @param int $width The new terminal width. + * @param int $height The new terminal height. + * @return void + */ + #[Override] + public function onScreenResize(int $width, int $height): void + { + parent::onScreenResize($width, $height); + + if ($this->player) { + $this->camera->resetPosition($this->player); + } + + $this->locationHUDWindow?->refreshLayout(); + + Console::clear(); + + if ($this->state instanceof FieldState) { + $this->state->renderTheField(); + return; + } + + $this->state?->enter(); + } +} diff --git a/src/Scenes/Game/States/EquipmentMenuState.php b/src/Scenes/Game/States/EquipmentMenuState.php index 1736871..faad135 100644 --- a/src/Scenes/Game/States/EquipmentMenuState.php +++ b/src/Scenes/Game/States/EquipmentMenuState.php @@ -18,11 +18,9 @@ use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\IO\Console\Console; -use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Scenes\SceneStateContext; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; -use Ichiloto\Engine\Util\Debug; /** * Represents the equipment menu state. @@ -130,6 +128,10 @@ class EquipmentMenuState extends GameSceneState */ public function execute(?SceneStateContext $context = null): void { + if ($this->handleCharacterCycling()) { + return; + } + $this->mode->update(); } @@ -140,6 +142,7 @@ public function enter(): void { Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); + $this->character ??= $this->getGameScene()->party->leader; $this->calculateMargins(); $this->initializeMenuUI(); $this->setMode(new EquipmentMenuCommandSelectionMode($this)); @@ -160,7 +163,7 @@ public function exit(): void */ protected function calculateMargins(): void { - $this->leftMargin = (get_screen_width() - self::EQUIPMENT_MENU_WIDTH) / 2; + $this->leftMargin = max(0, intdiv(get_screen_width() - self::EQUIPMENT_MENU_WIDTH, 2)); $this->topMargin = 0; } @@ -289,6 +292,7 @@ public function execute(?ExecutionContextInterface $context = null): int new Rect($this->leftMargin, $this->topMargin + self::EQUIPMENT_COMMAND_PANEL_HEIGHT + self::EQUIPMENT_ASSIGNMENT_PANEL_HEIGHT, self::EQUIPMENT_INFO_PANEL_WIDTH, self::EQUIPMENT_INFO_PANEL_HEIGHT), $this->borderPack ); + $this->equipmentInfoPanel->setHelp('tab:Next s-tab:Prev esc:Back'); $this->renderUI(); } @@ -358,4 +362,52 @@ protected function renderUI(): void $this->equipmentInfoPanel->render(); $this->equipmentAssignmentPanel->setSlots($this->character?->equipment ?? []); } -} \ No newline at end of file + + /** + * Handles character cycling shortcuts for single-character equipment screens. + * + * Cycling characters always resets the equipment screen back to command + * selection so the newly selected actor starts from a predictable state. + * + * @return bool True if the active character changed. + */ + protected function handleCharacterCycling(): bool + { + if ($this->isNextCharacterRequested()) { + $this->selectCharacterByOffset(1); + return true; + } + + if ($this->isPreviousCharacterRequested()) { + $this->selectCharacterByOffset(-1); + return true; + } + + return false; + } + + /** + * Selects a different party member and refreshes the equipment screen. + * + * @param int $offset The relative direction to move through the party. + * @return void + */ + protected function selectCharacterByOffset(int $offset): void + { + $characters = $this->getGameScene()->party->members->toArray(); + $totalCharacters = count($characters); + + if ($totalCharacters < 2) { + return; + } + + $currentIndex = array_search($this->character, $characters, true); + $currentIndex = ($currentIndex === false) ? 0 : $currentIndex; + $nextIndex = wrap($currentIndex + $offset, 0, $totalCharacters - 1); + $this->character = $characters[$nextIndex]; + + $this->setMode(new EquipmentMenuCommandSelectionMode($this)); + $this->characterDetailPanel->setDetails($this->character); + $this->equipmentAssignmentPanel->setSlots($this->character->equipment); + } +} diff --git a/src/Scenes/Game/States/FieldState.php b/src/Scenes/Game/States/FieldState.php index 147e9fd..1c5a940 100644 --- a/src/Scenes/Game/States/FieldState.php +++ b/src/Scenes/Game/States/FieldState.php @@ -47,8 +47,8 @@ class FieldState extends GameSceneState public function enter(): void { parent::enter(); - $this->renderTheField(); $this->getGameScene()->locationHUDWindow->activate(); + $this->renderTheField(); } /** @@ -85,6 +85,7 @@ public function renderTheField(): void */ public function resume(): void { + $this->getGameScene()->locationHUDWindow->activate(); $this->renderTheField(); } @@ -182,4 +183,4 @@ public function showInGameMap(): void { // TODO: Implement the in-game map feature. } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/States/GameSceneState.php b/src/Scenes/Game/States/GameSceneState.php index 68803d5..34c4c2f 100644 --- a/src/Scenes/Game/States/GameSceneState.php +++ b/src/Scenes/Game/States/GameSceneState.php @@ -4,6 +4,8 @@ use Ichiloto\Engine\Core\Interfaces\CanResume; use Ichiloto\Engine\Entities\Party; +use Ichiloto\Engine\IO\Enumerations\KeyCode; +use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Scenes\Interfaces\SceneStateContextInterface; use Ichiloto\Engine\Scenes\Interfaces\SceneStateInterface; @@ -111,4 +113,24 @@ protected function quitGame(): void { $this->context->getScene()->getGame()->quit(); } -} \ No newline at end of file + + /** + * Determines whether the player requested the next character-focused view. + * + * @return bool True when the next-character shortcut is pressed. + */ + protected function isNextCharacterRequested(): bool + { + return Input::isButtonDown('character_next') || Input::isKeyDown(KeyCode::TAB); + } + + /** + * Determines whether the player requested the previous character-focused view. + * + * @return bool True when the previous-character shortcut is pressed. + */ + protected function isPreviousCharacterRequested(): bool + { + return Input::isButtonDown('character_previous') || Input::isKeyDown(KeyCode::SHIFT_TAB); + } +} diff --git a/src/Scenes/Game/States/ItemMenuState.php b/src/Scenes/Game/States/ItemMenuState.php index 1bf8bda..02f6945 100644 --- a/src/Scenes/Game/States/ItemMenuState.php +++ b/src/Scenes/Game/States/ItemMenuState.php @@ -180,7 +180,7 @@ public function erase(): void */ protected function calculateMargins(): void { - $this->leftMargin = (get_screen_width() - self::ITEM_MENU_WIDTH) / 2; + $this->leftMargin = max(0, intdiv(get_screen_width() - self::ITEM_MENU_WIDTH, 2)); $this->topMargin = 0; } @@ -356,4 +356,4 @@ public function suspend(): void { $this->exit(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/States/MainMenuState.php b/src/Scenes/Game/States/MainMenuState.php index 4dd0c96..d1febb2 100644 --- a/src/Scenes/Game/States/MainMenuState.php +++ b/src/Scenes/Game/States/MainMenuState.php @@ -249,7 +249,7 @@ protected function initializeMenuUI(): void */ protected function calculateMargins(): void { - $this->leftMargin = (get_screen_width() - self::MAIN_MENU_WIDTH) / 2; + $this->leftMargin = max(0, intdiv(get_screen_width() - self::MAIN_MENU_WIDTH, 2)); $this->topMargin = 0; } @@ -305,4 +305,4 @@ public function erase(): void { Console::clear(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/States/ShopState.php b/src/Scenes/Game/States/ShopState.php index 5d48fe9..9dddb6e 100644 --- a/src/Scenes/Game/States/ShopState.php +++ b/src/Scenes/Game/States/ShopState.php @@ -223,7 +223,7 @@ public function suspend(): void */ protected function calculateMargins(): void { - $this->leftMargin = (get_screen_width() - self::SHOP_MENU_WIDTH) / 2; + $this->leftMargin = max(0, intdiv(get_screen_width() - self::SHOP_MENU_WIDTH, 2)); $this->topMargin = 0; } @@ -367,4 +367,4 @@ public function setMode(?ShopMenuMode $mode): void $this->mode = $mode; $this->mode->enter(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/States/StatusViewState.php b/src/Scenes/Game/States/StatusViewState.php index 9eb4aaf..0ce3c7a 100644 --- a/src/Scenes/Game/States/StatusViewState.php +++ b/src/Scenes/Game/States/StatusViewState.php @@ -7,14 +7,17 @@ use Ichiloto\Engine\Entities\Character; use Ichiloto\Engine\Entities\EquipmentSlot; use Ichiloto\Engine\IO\Console\Console; -use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Input; use Ichiloto\Engine\Scenes\SceneStateContext; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\UI\Windows\Window; -use Ichiloto\Engine\Util\Debug; +/** + * Displays one party member's full profile and allows cycling between members. + * + * @package Ichiloto\Engine\Scenes\Game\States + */ class StatusViewState extends GameSceneState { protected const int PROFILE_SUMMARY_PANEL_WIDTH = 110; @@ -74,6 +77,7 @@ public function enter(): void { Console::clear(); $this->getGameScene()->locationHUDWindow->deactivate(); + $this->character ??= $this->getGameScene()->party->leader; $this->calculateMargins(); $this->initializeUI(); } @@ -99,7 +103,7 @@ public function execute(?SceneStateContext $context = null): void */ protected function calculateMargins(): void { - $this->leftMargin = (get_screen_width() - self::PROFILE_SUMMARY_PANEL_WIDTH) / 2; + $this->leftMargin = max(0, intdiv(get_screen_width() - self::PROFILE_SUMMARY_PANEL_WIDTH, 2)); $this->topMargin = 0; } @@ -138,7 +142,7 @@ protected function initializeUI(): void ); $this->infoPanel = new Window( 'Info', - 'esc:Back', + 'tab:Next s-tab:Prev esc:Back', new Vector2($this->leftMargin, $this->topMargin + self::PROFILE_SUMMARY_PANEL_HEIGHT + self::STATS_SUMMARY_PANEL_HEIGHT), self::INFO_PANEL_WIDTH, self::INFO_PANEL_HEIGHT, @@ -220,14 +224,13 @@ protected function renderUI(): void */ protected function handleNavigation(): void { - $h = Input::getAxis(AxisName::HORIZONTAL); + if ($this->isNextCharacterRequested()) { + $this->selectNextCharacter(); + return; + } - if (abs($h) > 0) { - if ($h > 0) { - $this->selectNextCharacter(); - } else { - $this->selectPreviousCharacter(); - } + if ($this->isPreviousCharacterRequested()) { + $this->selectPreviousCharacter(); } } @@ -266,4 +269,4 @@ protected function selectNextCharacter(): void $this->character = $this->getGameScene()->party->members->toArray()[$previousCharacterIndex]; $this->updateContent(); } -} \ No newline at end of file +} diff --git a/src/Scenes/GameOver/GameOverScene.php b/src/Scenes/GameOver/GameOverScene.php index 31e053f..57217a1 100644 --- a/src/Scenes/GameOver/GameOverScene.php +++ b/src/Scenes/GameOver/GameOverScene.php @@ -7,6 +7,7 @@ use Ichiloto\Engine\Core\Menu\Commands\QuitGameCommand; use Ichiloto\Engine\Core\Menu\Commands\ToTitleMenuCommand; use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Scenes\AbstractScene; use Ichiloto\Engine\Scenes\Game\GameLoader; @@ -94,7 +95,7 @@ public function renderHeader(): void $headerWidth = max($headerWidth, mb_strlen($line)); } - $x = intval((DEFAULT_SCREEN_WIDTH - $headerWidth) / 2); + $x = intval((get_screen_width() - $headerWidth) / 2); $y = 2; $this->camera->draw($this->headerContent, $x, $y); @@ -119,4 +120,26 @@ public function suspend(): void { Console::clear(); } -} \ No newline at end of file + + /** + * Re-centers the game-over layout after a terminal resize. + * + * @param int $width The new terminal width. + * @param int $height The new terminal height. + * @return void + */ + public function onScreenResize(int $width, int $height): void + { + parent::onScreenResize($width, $height); + + if (! isset($this->menu)) { + return; + } + + $this->menu->setPosition(new Vector2(max(0, intdiv(get_screen_width() - 16, 2)), $this->headerHeight + 2)); + + Console::clear(); + $this->renderHeader(); + $this->menu->render(); + } +} diff --git a/src/Scenes/SceneManager.php b/src/Scenes/SceneManager.php index 3788ea8..be2d03e 100644 --- a/src/Scenes/SceneManager.php +++ b/src/Scenes/SceneManager.php @@ -21,6 +21,7 @@ use Ichiloto\Engine\Scenes\Battle\BattleConfig; use Ichiloto\Engine\Scenes\Battle\BattleLoader; use Ichiloto\Engine\Scenes\Battle\BattleScene; +use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\Scenes\GameOver\GameOverScene; use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; @@ -247,4 +248,19 @@ public function loadBattleScene(Party $party, Troop $troop, array $events = []): $currentScene->configure($this->battleLoader->newConfig($party, $troop, $events)); } -} \ No newline at end of file + + /** + * Returns the player to the game scene and re-renders the field. + * + * @return void + * @throws NotFoundException If the game scene cannot be found. + */ + public function returnFromBattleScene(): void + { + $currentScene = $this->loadScene(GameScene::class)->currentScene; + + if ($currentScene instanceof GameScene) { + $currentScene->resume(); + } + } +} diff --git a/src/Scenes/Title/TitleScene.php b/src/Scenes/Title/TitleScene.php index 8a21641..dfc13e5 100644 --- a/src/Scenes/Title/TitleScene.php +++ b/src/Scenes/Title/TitleScene.php @@ -8,6 +8,7 @@ use Ichiloto\Engine\Core\Menu\Commands\QuitGameCommand; use Ichiloto\Engine\Core\Menu\TitleMenu\TitleMenu; use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Scenes\AbstractScene; use Ichiloto\Engine\Scenes\Game\GameLoader; @@ -104,7 +105,7 @@ public function renderHeader(): void $headerWidth = max($headerWidth, mb_strlen($line)); } - $x = intval((DEFAULT_SCREEN_WIDTH - $headerWidth) / 2); + $x = intval((get_screen_width() - $headerWidth) / 2); $y = 2; $this->camera->draw($this->headerContent, $x, $y); @@ -129,4 +130,27 @@ public function suspend(): void { Console::clear(); } -} \ No newline at end of file + + /** + * Re-centers the title layout after a terminal resize. + * + * @param int $width The new terminal width. + * @param int $height The new terminal height. + * @return void + */ + #[Override] + public function onScreenResize(int $width, int $height): void + { + parent::onScreenResize($width, $height); + + if (! isset($this->menu)) { + return; + } + + $this->menu->setPosition(new Vector2(max(0, intdiv(get_screen_width() - 16, 2)), $this->headerHeight + 2)); + + Console::clear(); + $this->renderHeader(); + $this->menu->render(); + } +} diff --git a/src/UI/Elements/LocationHUDWindow.php b/src/UI/Elements/LocationHUDWindow.php index 73e4d17..bf639c6 100644 --- a/src/UI/Elements/LocationHUDWindow.php +++ b/src/UI/Elements/LocationHUDWindow.php @@ -31,7 +31,7 @@ class LocationHUDWindow extends Window implements UIElementInterface /** * @inheritDoc */ - protected(set) bool $isActive = true; + protected(set) bool $isActive = false; /** * LocationHUDWindow constructor. @@ -47,7 +47,7 @@ public function __construct( ) { $leftMargin = 1; - $topMargin = DEFAULT_SCREEN_HEIGHT - self::HEIGHT; + $topMargin = get_screen_height() - self::HEIGHT; parent::__construct('', '', new Vector2($leftMargin, $topMargin), self::WIDTH, self::HEIGHT, $borderPack); $this->updateDetails($coordinates, $heading); } @@ -70,6 +70,16 @@ public function updateDetails(Vector2 $coordinates, MovementHeading $heading): v $this->render(); } + /** + * Repositions the HUD to match the current screen size. + * + * @return void + */ + public function refreshLayout(): void + { + $this->setPosition(new Vector2(1, get_screen_height() - self::HEIGHT)); + } + /** * @inheritDoc */ @@ -96,4 +106,4 @@ public function deactivate(): void { $this->isActive = false; } -} \ No newline at end of file +} diff --git a/src/UI/Modal/Modal.php b/src/UI/Modal/Modal.php index e6a9bd3..149a29b 100644 --- a/src/UI/Modal/Modal.php +++ b/src/UI/Modal/Modal.php @@ -12,6 +12,7 @@ use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; use Ichiloto\Engine\Events\ModalEvent; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\IO\Input; @@ -316,7 +317,7 @@ protected function cancel(): void */ protected function renderTopBorder(): void { - $titleLength = strlen($this->title); + $titleLength = TerminalText::displayWidth($this->title); $horizontalBorder = str_repeat($this->borderPack->getHorizontalBorder(), $this->rect->getWidth() - $titleLength - 3); $output = $this->borderPack->getTopLeftCorner() . $this->borderPack->getHorizontalBorder() . @@ -333,7 +334,7 @@ protected function renderTopBorder(): void */ protected function renderBottomBorder(): void { - $helpLength = strlen($this->help); + $helpLength = TerminalText::displayWidth($this->help); $horizontalBorder = str_repeat($this->borderPack->getHorizontalBorder(), $this->rect->getWidth() - $helpLength - 3); $output = $this->borderPack->getBottomLeftCorner() . $this->borderPack->getHorizontalBorder() . @@ -352,7 +353,7 @@ protected function renderContent(): void { foreach ($this->content as $line) { $output = $this->borderPack->getVerticalBorder() . - str_pad($line, $this->rect->getWidth() - 2, ' ', STR_PAD_BOTH) . + TerminalText::padCenter($line, $this->rect->getWidth() - 2) . $this->borderPack->getVerticalBorder(); $this->output->write($output); } @@ -367,16 +368,11 @@ protected function renderButtons(): void { $activeColor = Color::LIGHT_BLUE; $buttonOutput = implode(' ', $this->buttons); - $buttonOutputLength = strlen($buttonOutput); - $padding = (int) (($this->rect->getWidth() - 2 - $buttonOutputLength) / 2); - $output = str_repeat(' ', $padding) . $buttonOutput . str_repeat(' ', $padding); - if (strlen($output) % 2 !== 0) { - $output .= ' '; - } + $output = TerminalText::padCenter($buttonOutput, $this->rect->getWidth() - 2); $output = $this->borderPack->getVerticalBorder() . str_replace($this->buttons[$this->activeIndex] ?? '', Color::apply($this->buttons[$this->activeIndex] ?? '', $activeColor), $output) . $this->borderPack->getVerticalBorder(); $this->output->write($output); } -} \ No newline at end of file +} diff --git a/src/UI/Modal/SelectModal.php b/src/UI/Modal/SelectModal.php index 7aba068..e5fd3cf 100644 --- a/src/UI/Modal/SelectModal.php +++ b/src/UI/Modal/SelectModal.php @@ -11,6 +11,7 @@ use Ichiloto\Engine\Events\ModalEvent; use Ichiloto\Engine\Events\ObservableTrait; use Ichiloto\Engine\IO\Console\Console; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\AxisName; use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\IO\Enumerations\KeyCode; @@ -57,7 +58,7 @@ class SelectModal implements ModalInterface } set { $this->title = $value; - $this->titleLength = strlen($value); + $this->titleLength = TerminalText::displayWidth($value); } } /** @@ -178,7 +179,7 @@ public function setOptions(array $options): void $totalOptions = 0; foreach ($this->options as $option) { $totalOptions++; - $this->rect->setWidth(max($this->rect->getWidth(), strlen($option) + 6)); + $this->rect->setWidth(max($this->rect->getWidth(), TerminalText::displayWidth($option) + 6)); } $this->totalOptions = $totalOptions; } @@ -321,7 +322,7 @@ public function getContent(): string public function setContent(string $content): void { $this->message = $content; - $this->messageLength = strlen($this->message); + $this->messageLength = TerminalText::displayWidth($this->message); } public function getHelp(): string @@ -332,7 +333,7 @@ public function getHelp(): string public function setHelp(string $help): void { $this->help = $help; - $this->helpLength = strlen($this->help); + $this->helpLength = TerminalText::displayWidth($this->help); } public function getHelpLength(): int @@ -440,7 +441,7 @@ protected function renderOptions(int $x, int $y): void if ($this->message) { foreach ($this->messageLines as $lineIndex => $line) { $output = $this->borderPack->getVerticalBorder(); - $output .= sprintf(" %-{$lineSize}s", $line); + $output .= ' ' . TerminalText::padRight($line, $lineSize - 1); $output .= $this->borderPack->getVerticalBorder(); Console::cursor()->moveTo($x, $y + $lineIndex); $this->output->write($output); @@ -453,7 +454,7 @@ protected function renderOptions(int $x, int $y): void foreach ($this->options as $optionIndex => $option) { $output = $this->borderPack->getVerticalBorder(); $prefix = $optionIndex === $this->activeOptionIndex ? '>' : ' '; - $content = sprintf(" %s %-{$spacing}s", $prefix, $option); + $content = " {$prefix} " . TerminalText::padRight($option, $spacing); if ($optionIndex === $this->activeOptionIndex) { $content = Color::apply($content, Color::LIGHT_BLUE); @@ -507,4 +508,4 @@ protected function cancel(): void $this->value = -1; $this->hide(); } -} \ No newline at end of file +} diff --git a/src/UI/Windows/CommandPanel.php b/src/UI/Windows/CommandPanel.php index 2c94af7..7fd9515 100644 --- a/src/UI/Windows/CommandPanel.php +++ b/src/UI/Windows/CommandPanel.php @@ -6,6 +6,7 @@ use Ichiloto\Engine\Core\Menu\Interfaces\MenuInterface; use Ichiloto\Engine\Core\Menu\MenuItem; use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; /** @@ -97,7 +98,7 @@ public function updateContent(): void /** @var MenuItem $item */ foreach ($this->menu->getItems() as $index => $item) { $prefix = $index === $this->menu->activeIndex ? '>' : ' '; - $content .= sprintf(" %s %-12s", $prefix, $item->getLabel()); + $content .= ' ' . $prefix . ' ' . TerminalText::padRight((string)$item, 12); } if (!is_iterable($content)) { @@ -107,4 +108,4 @@ public function updateContent(): void $this->setContent($content); $this->render(); } -} \ No newline at end of file +} diff --git a/src/UI/Windows/Window.php b/src/UI/Windows/Window.php index e61c8bb..1b1a785 100644 --- a/src/UI/Windows/Window.php +++ b/src/UI/Windows/Window.php @@ -8,6 +8,7 @@ use Ichiloto\Engine\Events\Interfaces\ObserverInterface; use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\Cursor; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Enumerations\HorizontalAlignment; @@ -96,10 +97,11 @@ public function render(?int $x = null, ?int $y = null): void $linesOfContent = $this->getLinesOfContent(); foreach ($linesOfContent as $index => $line) { $this->cursor->moveTo($leftMargin, $topMargin + $index + 1); + $output = TerminalText::truncateToWidth($line, $this->width); if ($this->foregroundColor) { - $this->output->write($this->foregroundColor->value . mb_substr($line, 0, $this->width) . Color::RESET->value); + $this->output->write($this->foregroundColor->value . $output . Color::RESET->value); } else { - $this->output->write($line); + $this->output->write($output); } } @@ -286,7 +288,7 @@ public function setBackgroundColor(Color $backgroundColor): void */ private function getTopBorder(): string { - $titleLength = mb_strlen($this->title); + $titleLength = TerminalText::displayWidth($this->title); $borderLength = $this->width - $titleLength - 3; $output = $this->borderPack->getTopLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->title; $output .= str_repeat($this->borderPack->getHorizontalBorder(), max($borderLength, 0)); @@ -342,7 +344,7 @@ private function getLinesOfContent(): array */ private function getBottomBorder(): string { - $helpLength = mb_strlen($this->help); + $helpLength = TerminalText::displayWidth($this->help); $output = $this->borderPack->getBottomLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->help; $output .= str_repeat($this->borderPack->getHorizontalBorder(), max($this->width - $helpLength - 3, 0)); $output .= $this->borderPack->getBottomRightCorner(); @@ -358,14 +360,17 @@ private function getBottomBorder(): string private function getLeftAlignedContent(): array { $leftAlignedContent = []; + $innerWidth = $this->width - 2; foreach ($this->content as $content) { - $contentLength = mb_strlen($content); $leftPaddingLength = $this->padding->getLeftPadding(); - $rightPaddingLength = $this->width - $contentLength - $this->padding->getRightPadding() - 2; + $rightPaddingLength = $this->padding->getRightPadding(); + $availableWidth = max(0, $innerWidth - $leftPaddingLength - $rightPaddingLength); $output = $this->borderPack->getVerticalBorder(); - $output .= $this->padContent($content, $leftPaddingLength, $rightPaddingLength, $this->width - 2); + $output .= str_repeat(' ', $leftPaddingLength); + $output .= TerminalText::padRight($content, $availableWidth); + $output .= str_repeat(' ', $rightPaddingLength); $output .= $this->borderPack->getVerticalBorder(); $leftAlignedContent[] = $output; @@ -382,19 +387,17 @@ private function getLeftAlignedContent(): array private function getCenterAlignedContent(): array { $centerAlignedContent = []; + $innerWidth = $this->width - 2; foreach ($this->content as $content) { - $contentLength = mb_strlen($content); - $totalPadding = $this->width - $this->padding->getLeftPadding() - $contentLength - $this->padding->getRightPadding() - 2; - $leftPaddingLength = max(floor($totalPadding / 2), 0); - $rightPaddingLength = max(ceil($totalPadding / 2), 0); + $leftPaddingLength = $this->padding->getLeftPadding(); + $rightPaddingLength = $this->padding->getRightPadding(); + $availableWidth = max(0, $innerWidth - $leftPaddingLength - $rightPaddingLength); $output = $this->borderPack->getVerticalBorder(); - $contentRender = str_repeat(' ', max($leftPaddingLength, 0)); - $contentRender .= $content; - $contentRender .= str_repeat(' ', max($rightPaddingLength, 0)); - - $output .= str_pad($contentRender, $this->width - 2, ' ', STR_PAD_BOTH); + $output .= str_repeat(' ', max($leftPaddingLength, 0)); + $output .= TerminalText::padCenter($content, $availableWidth); + $output .= str_repeat(' ', max($rightPaddingLength, 0)); $output .= $this->borderPack->getVerticalBorder(); $centerAlignedContent[] = $output; @@ -411,15 +414,16 @@ private function getCenterAlignedContent(): array private function getRightAlignedContent(): array { $rightAlignedContent = []; + $innerWidth = $this->width - 2; foreach ($this->content as $content) { - $contentLength = mb_strlen($content); - $leftPaddingLength = $this->width - $contentLength - $this->padding->getLeftPadding() - 2; - $rightPaddingLength = $this->padding->getRightPadding(); // -1 for the border + $leftPaddingLength = $this->padding->getLeftPadding(); + $rightPaddingLength = $this->padding->getRightPadding(); + $availableWidth = max(0, $innerWidth - $leftPaddingLength - $rightPaddingLength); $output = $this->borderPack->getVerticalBorder(); $output .= str_repeat(' ', max($leftPaddingLength, 0)); - $output .= $content; + $output .= TerminalText::padLeft($content, $availableWidth); $output .= str_repeat(' ', max($rightPaddingLength, 0)); $output .= $this->borderPack->getVerticalBorder(); @@ -467,23 +471,4 @@ public function getPosition(): Vector2 return $this->position; } - /** - * @param string $content - * @param int $leftPaddingLength - * @param int $rightPaddingLength - * @param int $maxLength - * @return string - */ - private function padContent(string $content, int $leftPaddingLength, int $rightPaddingLength, int $maxLength = -1): string - { - $ansiRegex = '/\033\[[0-9;]*m/'; - $strippedString = preg_replace($ansiRegex, '', $content); - - $contentLength = mb_strlen($content) - 3; - - $leftPadding = str_repeat(' ', max($leftPaddingLength, 0)); - $rightPadding = str_repeat(' ', max($rightPaddingLength, 0)); - - return mb_substr($leftPadding . $content . $rightPadding, 0, max($contentLength, $maxLength)); - } -} \ No newline at end of file +} diff --git a/src/Util/Helpers.php b/src/Util/Helpers.php index d7ef002..0c86aaa 100644 --- a/src/Util/Helpers.php +++ b/src/Util/Helpers.php @@ -448,7 +448,11 @@ function get_message(string $path, string $default): string */ function get_screen_width(): int { - return config(PlaySettings::class, 'width', DEFAULT_SCREEN_WIDTH); + return config( + PlaySettings::class, + 'width', + config(PlaySettings::class, 'screen.width', DEFAULT_SCREEN_WIDTH) + ); } } @@ -460,7 +464,11 @@ function get_screen_width(): int */ function get_screen_height(): int { - return config(PlaySettings::class, 'height', DEFAULT_SCREEN_HEIGHT); + return config( + PlaySettings::class, + 'height', + config(PlaySettings::class, 'screen.height', DEFAULT_SCREEN_HEIGHT) + ); } } @@ -611,4 +619,4 @@ function generate_experience_curve( return $curveValues; } -} \ No newline at end of file +} diff --git a/tests/Pest.php b/tests/Pest.php index fd279ad..ac08b48 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,10 @@ newInstanceWithoutConstructor(); + + return new class($uiManager) implements SceneInterface { + public Camera $camera; + public string $name = 'Test'; + + public function __construct(private UIManager $uiManager) + { + } + + public function start(): void + { + } + + public function stop(): void + { + } + + public function resume(): void + { + } + + public function suspend(): void + { + } + + public function render(): void + { + } + + public function erase(): void + { + } + + public function update(): void + { + } + + public function getGame(): Game + { + throw new RuntimeException('Not required for camera unit tests.'); + } + + public function getRootGameObjects(): array + { + return []; + } + + public function getUI(): UIManager + { + return $this->uiManager; + } + + public function isStarted(): bool + { + return true; + } + + public function renderBackgroundTile(int $x, int $y): void + { + } + }; } diff --git a/tests/Unit/AttackActionTest.php b/tests/Unit/AttackActionTest.php new file mode 100644 index 0000000..b0e0e0a --- /dev/null +++ b/tests/Unit/AttackActionTest.php @@ -0,0 +1,17 @@ +stats->currentHp; + + $action->execute($actor, [$target]); + + expect($target->stats->currentHp)->toBeLessThan($beforeHp); +}); diff --git a/tests/Unit/BattleGroupTest.php b/tests/Unit/BattleGroupTest.php new file mode 100644 index 0000000..b8a041c --- /dev/null +++ b/tests/Unit/BattleGroupTest.php @@ -0,0 +1,48 @@ +addMember(new Character('One', 0, new Stats(currentHp: 0, currentMp: 0))); + $party->addMember(new Character('Two', 0, new Stats(currentHp: 0, currentMp: 0))); + + expect($party->isDefeated())->toBeTrue(); +}); + +it('does not mark a party as defeated while a battler is still conscious', function () { + $party = new Party(); + $party->addMember(new Character('One', 0, new Stats(currentHp: 0, currentMp: 0))); + $party->addMember(new Character('Two', 0, new Stats(currentHp: 10, currentMp: 0))); + + expect($party->isDefeated())->toBeFalse(); +}); + +it('tracks remaining equipment copies after party members equip them', function () { + $party = new Party(); + $weapon = Weapon::fromArray([ + 'name' => 'Wooden Sword', + 'description' => 'A simple sword.', + 'icon' => '/', + 'quantity' => 4, + 'parameterChanges' => new ParameterChanges(attack: 2), + ]); + $firstCharacter = new Character('One', 0, new Stats()); + $secondCharacter = new Character('Two', 0, new Stats()); + + $party->addMember($firstCharacter); + $party->addMember($secondCharacter); + $party->inventory->addItems($weapon); + + $firstWeaponSlot = $firstCharacter->equipment[0]; + $firstWeaponSlot->equipment = $weapon; + expect($party->getAvailableEquipmentQuantity($weapon))->toBe(3); + + $secondWeaponSlot = $secondCharacter->equipment[0]; + $secondWeaponSlot->equipment = $weapon; + expect($party->getAvailableEquipmentQuantity($weapon))->toBe(2); +}); diff --git a/tests/Unit/BattlePacingTest.php b/tests/Unit/BattlePacingTest.php new file mode 100644 index 0000000..20ab456 --- /dev/null +++ b/tests/Unit/BattlePacingTest.php @@ -0,0 +1,18 @@ +getMessageDurationSeconds())->toBe(2.5); +}); + +it('matches the slow physical attack timing budget', function () { + $pacing = new BattlePacing(BattlePace::SLOW, BattlePace::SLOW); + $timings = $pacing->getTurnTimings(new AttackAction('Attack')); + + expect(round($timings->totalDurationSeconds(), 1))->toBe(4.0); +}); diff --git a/tests/Unit/CameraTest.php b/tests/Unit/CameraTest.php new file mode 100644 index 0000000..bc04f92 --- /dev/null +++ b/tests/Unit/CameraTest.php @@ -0,0 +1,47 @@ +camera = $camera; + + $screenPosition = $camera->getScreenSpacePosition(new Vector2(0, 0)); + + expect($screenPosition->x)->toBe(30.0) + ->and($screenPosition->y)->toBe(20.0); +}); + +it('keeps large maps anchored to the viewport while scrolling', function () { + $scene = makeCameraTestScene(); + $camera = new Camera($scene, 80, 50, new Vector2(10, 5), null, array_fill(0, 60, str_repeat('.', 120))); + $scene->camera = $camera; + + $screenPosition = $camera->getScreenSpacePosition(new Vector2(25, 15)); + + expect($screenPosition->x)->toBe(15.0) + ->and($screenPosition->y)->toBe(10.0); +}); + +it('uses the upper middle cell as the focus point on even viewports', function () { + $scene = makeCameraTestScene(); + $camera = new Camera($scene, 80, 50); + $scene->camera = $camera; + + expect($camera->getHorizontalFocusPosition())->toBe(39) + ->and($camera->getVerticalFocusPosition())->toBe(24); +}); + +it('re-centers smaller maps after the viewport is resized', function () { + $scene = makeCameraTestScene(); + $camera = new Camera($scene, 80, 50, new Vector2(0, 0), null, array_fill(0, 10, str_repeat('.', 20))); + $scene->camera = $camera; + + $camera->resizeViewport(100, 60); + $screenPosition = $camera->getScreenSpacePosition(new Vector2(0, 0)); + + expect($screenPosition->x)->toBe(40.0) + ->and($screenPosition->y)->toBe(25.0); +}); diff --git a/tests/Unit/ColoredCameraTest.php b/tests/Unit/ColoredCameraTest.php new file mode 100644 index 0000000..3c516a4 --- /dev/null +++ b/tests/Unit/ColoredCameraTest.php @@ -0,0 +1,19 @@ +o' . + '.' . + 'x' + ); + $camera = new Camera($scene, 80, 50, new Vector2(0, 0), null, [$row]); + $scene->camera = $camera; + + expect($camera->worldSpaceWidth)->toBe(3) + ->and($camera->worldSpaceHeight)->toBe(1); +}); diff --git a/tests/Unit/TerminalTextTest.php b/tests/Unit/TerminalTextTest.php new file mode 100644 index 0000000..a4381b7 --- /dev/null +++ b/tests/Unit/TerminalTextTest.php @@ -0,0 +1,40 @@ +toBe(9) + ->and(TerminalText::symbolCount($text))->toBe(8) + ->and(TerminalText::stripAnsi($text))->toBe('Potion πŸ§ͺ'); +}); + +it('pads ansi colored text using visible width', function () { + $text = Color::apply('Rare', Color::YELLOW); + $padded = TerminalText::padRight($text, 8); + + expect(TerminalText::displayWidth($padded))->toBe(8) + ->and(TerminalText::stripAnsi($padded))->toBe('Rare '); +}); + +it('slices visible symbols without breaking ansi styling', function () { + $text = Color::apply('o', Color::LIGHT_GREEN) . 'x?'; + $slice = TerminalText::sliceSymbols($text, 0, 2); + + expect(TerminalText::stripAnsi($slice))->toBe('ox') + ->and(TerminalText::symbolCount($slice))->toBe(2) + ->and($slice)->toContain("\033["); +}); + +it('treats symfony formatter tags as zero-width styling', function () { + $text = ';~'; + $symbols = TerminalText::visibleSymbols($text); + + expect(TerminalText::stripAnsi($text))->toBe(';~') + ->and(TerminalText::symbolCount($text))->toBe(2) + ->and(TerminalText::displayWidth($text))->toBe(2) + ->and($symbols[0] ?? '')->toContain("\033[") + ->and($symbols[1] ?? '')->toContain("\033["); +}); From 74e5821c2ee7977051da4d50a4b371d15026b422 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 01:06:39 +0200 Subject: [PATCH 2/4] test: add console buffer float coordinate flooring test --- tests/Unit/ConsoleTest.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/Unit/ConsoleTest.php diff --git a/tests/Unit/ConsoleTest.php b/tests/Unit/ConsoleTest.php new file mode 100644 index 0000000..b01ca33 --- /dev/null +++ b/tests/Unit/ConsoleTest.php @@ -0,0 +1,33 @@ +getProperty('width'); + $width->setAccessible(true); + $width->setValue(20); + + $height = $console->getProperty('height'); + $height->setAccessible(true); + $height->setValue(5); + + $buffer = $console->getProperty('buffer'); + $buffer->setAccessible(true); + $buffer->setValue(array_fill(0, 5, str_repeat(' ', 20))); + + $output = $console->getProperty('output'); + $output->setAccessible(true); + $output->setValue(null); + + ob_start(); + Console::write('Z', 10.8, 2.9); + ob_end_clean(); + + $bufferRows = Console::getBuffer(); + $symbols = TerminalText::visibleSymbols($bufferRows[2]); + + expect(TerminalText::stripAnsi($symbols[10] ?? ''))->toBe('Z'); +}); From 5ce25a14c218375d56e138ac912185dcb092d591 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 03:38:18 +0200 Subject: [PATCH 3/4] refactor: enhance battle screen and player sprite handling - Removed unused imports and optimized the BattleScreen class by introducing a selection highlight color. - Added methods to refresh layout and style selection lines in the BattleScreen. - Updated SleepAction to use the new setFacingSprite method for player sprite management. - Changed Player class to support directional sprites using arrays instead of strings. - Introduced PlayerSpriteSet class to manage player sprites based on movement headings. - Enhanced Console class to read terminal size from multiple sources for better compatibility. - Updated GameConfig and GameLoader to handle new player sprite configurations. - Added unit tests for BattleCommandWindow, BattleResultWindow, BattleScreen, Console, and PlayerSpriteSet to ensure functionality and correctness. --- src/Battle/BattleResult.php | 2 + .../States/ActionExecutionState.php | 6 +- .../Traditional/States/PlayerActionState.php | 4 +- .../Traditional/States/TurnInitState.php | 2 +- .../States/TurnResolutionState.php | 10 +- src/Battle/UI/BattleCharacterNameWindow.php | 47 ++++- src/Battle/UI/BattleCommandWindow.php | 29 ++- src/Battle/UI/BattleResultWindow.php | 186 +++++++++++++++++- src/Battle/UI/BattleScreen.php | 120 ++++++++++- src/Entities/Actions/SleepAction.php | 2 +- src/Field/Player.php | 112 ++++++++--- src/Field/PlayerSpriteSet.php | 118 +++++++++++ src/IO/Console/Console.php | 128 +++++++++++- src/Scenes/Battle/BattleScene.php | 38 ++++ .../Battle/States/BattleDefeatState.php | 9 + .../Battle/States/BattleVictoryState.php | 9 + src/Scenes/Game/GameConfig.php | 16 +- src/Scenes/Game/GameLoader.php | 51 +++-- src/Scenes/Game/GameScene.php | 5 +- tests/Unit/BattleCommandWindowTest.php | 36 ++++ tests/Unit/BattleResultWindowTest.php | 69 +++++++ tests/Unit/BattleScreenTest.php | 34 ++++ tests/Unit/ConsoleTest.php | 28 +++ tests/Unit/PlayerSpriteSetTest.php | 51 +++++ 24 files changed, 1033 insertions(+), 79 deletions(-) create mode 100644 src/Field/PlayerSpriteSet.php create mode 100644 tests/Unit/BattleCommandWindowTest.php create mode 100644 tests/Unit/BattleResultWindowTest.php create mode 100644 tests/Unit/BattleScreenTest.php create mode 100644 tests/Unit/PlayerSpriteSetTest.php diff --git a/src/Battle/BattleResult.php b/src/Battle/BattleResult.php index c2c2790..8d03c47 100644 --- a/src/Battle/BattleResult.php +++ b/src/Battle/BattleResult.php @@ -17,11 +17,13 @@ class BattleResult * @param string $title The result title. * @param string[] $lines The result summary lines. * @param InventoryItem[] $items The earned items. + * @param array $entries The staged reward entries. */ public function __construct( protected(set) string $title, protected(set) array $lines = [], protected(set) array $items = [], + protected(set) array $entries = [], ) { } diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php index 69a26e1..a181cc9 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/ActionExecutionState.php @@ -17,7 +17,7 @@ public function enter(TurnStateExecutionContext $context): void { $context->resetTurnCursor(); $context->ui->commandWindow->blur(); - $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->characterNameWindow->setActiveSelection(-1); $context->ui->commandContextWindow->clear(); $context->ui->hideMessage(); $context->ui->refresh(); @@ -128,7 +128,7 @@ protected function performTurnSequence( $this->displayPhase($context, $summary, $timings->statChanges); $this->displayPhase($context, 'Turn over.', $timings->turnOver, hideAfter: true); - $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->characterNameWindow->setActiveSelection(-1); } /** @@ -142,7 +142,7 @@ protected function highlightActor(TurnStateExecutionContext $context, CharacterI { $partyBattlers = $context->party->battlers->toArray(); $actorIndex = array_search($actor, $partyBattlers, true); - $context->ui->characterNameWindow->activeIndex = is_int($actorIndex) ? $actorIndex : -1; + $context->ui->characterNameWindow->setActiveSelection(is_int($actorIndex) ? $actorIndex : -1); } /** diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php index f584f41..92dcce0 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/PlayerActionState.php @@ -114,7 +114,7 @@ protected function loadCharacterActions(): void $engine = $this->engine; $ui = $engine->battleConfig->ui; - $ui->characterNameWindow->activeIndex = $this->activeCharacterIndex; + $ui->characterNameWindow->setActiveSelection($this->activeCharacterIndex); $ui->commandWindow->commands = array_map( fn(BattleAction $action) => $action->name, $this->activeCharacter->commandAbilities @@ -152,7 +152,7 @@ protected function selectNextCharacter(TurnStateExecutionContext $context, bool } $this->activeCharacterIndex = -1; - $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->characterNameWindow->setActiveSelection(-1); $context->ui->commandWindow->blur(); $context->ui->commandContextWindow->clear(); $this->setState($this->engine->enemyActionState); diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php index f0ccd79..7c6ba13 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnInitState.php @@ -75,7 +75,7 @@ protected function determineTurnOrder(TurnStateExecutionContext $context): void private function updateUI(TurnStateExecutionContext $context): void { $context->ui->characterStatusWindow->setCharacters($context->party->battlers->toArray()); - $context->ui->characterNameWindow->activeIndex = -1; + $context->ui->characterNameWindow->setActiveSelection(-1); $context->ui->commandContextWindow->clear(); $context->ui->refresh(); } diff --git a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php index f332791..20ef2a9 100644 --- a/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php +++ b/src/Battle/Engines/TurnBasedEngines/Traditional/States/TurnResolutionState.php @@ -51,12 +51,20 @@ public function update(TurnStateExecutionContext $context): void sprintf('Experience gained: %d', $experience), sprintf('Gold found: %dG', $gold), ]; + $entries = [ + ['label' => 'Experience gained:', 'value' => (string)$experience], + ['label' => 'Gold found:', 'value' => sprintf('%dG', $gold)], + ]; if (! empty($items)) { $lines[] = 'Loot: ' . implode(', ', array_map(fn($item) => $item->name, $items)); + $entries[] = [ + 'label' => 'Item drops:', + 'value' => implode(', ', array_map(fn($item) => $item->name, $items)), + ]; } - $scene->result = new BattleResult('Victory', $lines, $items); + $scene->result = new BattleResult('Victory', $lines, $items, $entries); $scene->setState($scene->victoryState); return; } diff --git a/src/Battle/UI/BattleCharacterNameWindow.php b/src/Battle/UI/BattleCharacterNameWindow.php index bfa2357..67c5b5e 100644 --- a/src/Battle/UI/BattleCharacterNameWindow.php +++ b/src/Battle/UI/BattleCharacterNameWindow.php @@ -3,9 +3,9 @@ namespace Ichiloto\Engine\Battle\UI; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Interfaces\CanFocus; use Ichiloto\Engine\UI\Windows\Window; -use Ichiloto\Engine\Util\Debug; /** * Represents the battle character name window. @@ -39,6 +39,10 @@ class BattleCharacterNameWindow extends Window implements CanFocus * @var string[] The names of the characters. */ protected(set) array $names = []; + /** + * @var bool Whether the active name should blink to show pending input. + */ + protected bool $blinkActiveSelection = false; public function __construct(protected BattleScreen $battleScreen) { @@ -75,10 +79,17 @@ public function setNames(array $names): void public function updateContent(): void { $content = []; + $availableWidth = $this->getContentWidth(); foreach ($this->names as $index => $name) { $prefix = $this->activeIndex === $index ? '>' : ' '; - $content[] = " $prefix $name"; + $line = TerminalText::padRight(" $prefix $name", $availableWidth); + + if ($this->activeIndex === $index) { + $line = $this->battleScreen->styleSelectionLine($line, $this->blinkActiveSelection); + } + + $content[] = $line; } $content = array_pad($content, self::HEIGHT - 2, ''); @@ -91,7 +102,7 @@ public function updateContent(): void */ public function focus(): void { - $this->activeIndex = 0; + $this->setActiveSelection(0, blink: true); } /** @@ -99,6 +110,32 @@ public function focus(): void */ public function blur(): void { - // Do nothing + $this->setActiveSelection(-1); + } + + /** + * Updates the active name and whether it should blink. + * + * @param int $index The active name index. + * @param bool $blink Whether the active name should blink. + * @return void + */ + public function setActiveSelection(int $index, bool $blink = false): void + { + $this->blinkActiveSelection = $blink; + $this->activeIndex = $index; + } + + /** + * Returns the width available for content inside the window frame. + * + * @return int The inner content width. + */ + protected function getContentWidth(): int + { + return max( + 0, + $this->width - 2 - $this->padding->getLeftPadding() - $this->padding->getRightPadding() + ); } -} \ No newline at end of file +} diff --git a/src/Battle/UI/BattleCommandWindow.php b/src/Battle/UI/BattleCommandWindow.php index f512d47..b54fcae 100644 --- a/src/Battle/UI/BattleCommandWindow.php +++ b/src/Battle/UI/BattleCommandWindow.php @@ -4,6 +4,7 @@ use Ichiloto\Engine\Core\Interfaces\CanChangeSelection; use Ichiloto\Engine\Core\Vector2; +use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\UI\Interfaces\CanFocus; use Ichiloto\Engine\UI\Windows\Window; @@ -26,6 +27,10 @@ class BattleCommandWindow extends Window implements CanFocus, CanChangeSelection * The index of the active command. */ protected(set) int $activeCommandIndex = -1; + /** + * @var bool Whether the active command should blink. + */ + protected bool $blinkActiveSelection = false; /** * The commands available to the player. */ @@ -69,6 +74,7 @@ public function __construct(protected BattleScreen $battleScreen) */ public function focus(): void { + $this->blinkActiveSelection = true; $this->activeCommandIndex = $this->totalCommands > 0 ? 0 : -1; $this->updateContent(); } @@ -78,6 +84,7 @@ public function focus(): void */ public function blur(): void { + $this->blinkActiveSelection = false; $this->activeCommandIndex = -1; $this->updateContent(); } @@ -98,10 +105,17 @@ public function clear(): void public function updateContent(): void { $content = []; + $availableWidth = $this->getContentWidth(); foreach ($this->commands as $index => $command) { $prefix = $this->activeCommandIndex === $index ? '>' : ' '; - $content[] = "$prefix $command"; + $line = TerminalText::padRight("$prefix $command", $availableWidth); + + if ($this->activeCommandIndex === $index) { + $line = $this->battleScreen->styleSelectionLine($line, $this->blinkActiveSelection); + } + + $content[] = $line; } $content = array_pad($content, $this->height - 2, ''); @@ -136,4 +150,17 @@ public function selectNext(): void $this->activeCommandIndex = $index; $this->updateContent(); } + + /** + * Returns the width available for content inside the window frame. + * + * @return int The inner content width. + */ + protected function getContentWidth(): int + { + return max( + 0, + $this->width - 2 - $this->padding->getLeftPadding() - $this->padding->getRightPadding() + ); + } } diff --git a/src/Battle/UI/BattleResultWindow.php b/src/Battle/UI/BattleResultWindow.php index 7581ae8..0e2da69 100644 --- a/src/Battle/UI/BattleResultWindow.php +++ b/src/Battle/UI/BattleResultWindow.php @@ -3,6 +3,7 @@ namespace Ichiloto\Engine\Battle\UI; use Ichiloto\Engine\Battle\BattleResult; +use Ichiloto\Engine\Core\Time; use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\UI\Windows\Window; use Ichiloto\Engine\UI\Windows\WindowAlignment; @@ -16,21 +17,40 @@ class BattleResultWindow extends Window { const int WIDTH = 64; const int HEIGHT = 8; + /** + * @var BattleResult|null The result currently being displayed. + */ + protected ?BattleResult $result = null; + /** + * @var int The number of reward values already revealed. + */ + protected int $revealedEntryCount = 0; + /** + * @var bool Whether the staged reveal is complete. + */ + protected bool $isRevealComplete = true; + /** + * @var float The next time a reward value should reveal. + */ + protected float $nextRevealAt = 0.0; + /** + * @var float The delay between reward reveals in seconds. + */ + protected float $revealDelay = 0.75; public function __construct(protected BattleScreen $battleScreen) { - $leftMargin = $this->battleScreen->screenDimensions->getLeft() + intval((BattleScreen::WIDTH - self::WIDTH) / 2); - $topMargin = $this->battleScreen->screenDimensions->getTop() + intval((BattleScreen::HEIGHT - self::HEIGHT) / 2); - parent::__construct( '', 'enter:Continue', - new Vector2($leftMargin, $topMargin), + new Vector2(), self::WIDTH, self::HEIGHT, $this->battleScreen->borderPack, WindowAlignment::middleLeft() ); + + $this->refreshLayout(); } /** @@ -41,16 +61,164 @@ public function __construct(protected BattleScreen $battleScreen) */ public function display(BattleResult $result): void { - $content = []; + $this->result = $result; + $this->revealedEntryCount = 0; $this->setTitle($result->title); - foreach ($result->lines as $line) { + if (! empty($result->entries)) { + $this->isRevealComplete = false; + $this->nextRevealAt = Time::getTime() + $this->revealDelay; + $this->setHelp('enter:Fast Forward'); + $this->refreshContent(); + return; + } + + $this->isRevealComplete = true; + $this->setHelp('enter:Continue'); + $this->setContent($this->buildWrappedLines($result->lines)); + $this->render(); + } + + /** + * Updates the staged result reveal. + * + * @return bool True if a new reward value was revealed. + */ + public function update(): bool + { + if ($this->isRevealComplete || ! $this->result || empty($this->result->entries)) { + return false; + } + + if (Time::getTime() < $this->nextRevealAt) { + return false; + } + + return $this->revealNextEntry(); + } + + /** + * Fast-forwards the current staged reward reveal. + * + * @return bool True when the result is already fully revealed. + */ + public function advance(): bool + { + if ($this->isRevealComplete) { + return true; + } + + $this->revealNextEntry(); + return false; + } + + /** + * Returns whether the staged reveal is complete. + * + * @return bool True when all entries are visible. + */ + public function isComplete(): bool + { + return $this->isRevealComplete; + } + + /** + * Re-centers the result window within the current battle frame. + * + * @return void + */ + public function refreshLayout(): void + { + $leftMargin = $this->battleScreen->screenDimensions->getLeft() + + intdiv($this->battleScreen->screenDimensions->getWidth() - self::WIDTH, 2); + $topMargin = $this->battleScreen->screenDimensions->getTop() + + intdiv($this->battleScreen->screenDimensions->getHeight() - self::HEIGHT, 2); + + $this->setPosition(new Vector2($leftMargin, $topMargin)); + } + + /** + * Reveals the next reward value and redraws the window content. + * + * @return bool True if a new reward value was revealed. + */ + protected function revealNextEntry(): bool + { + if (! $this->result || $this->isRevealComplete) { + return false; + } + + $entryCount = count($this->result->entries); + + if ($entryCount < 1) { + $this->isRevealComplete = true; + $this->setHelp('enter:Continue'); + return false; + } + + $this->revealedEntryCount = min($entryCount, $this->revealedEntryCount + 1); + $this->isRevealComplete = $this->revealedEntryCount >= $entryCount; + $this->nextRevealAt = Time::getTime() + $this->revealDelay; + $this->setHelp($this->isRevealComplete ? 'enter:Continue' : 'enter:Fast Forward'); + $this->refreshContent(); + + return true; + } + + /** + * Rebuilds the visible result content for the current reveal step. + * + * @return void + */ + protected function refreshContent(): void + { + if (! $this->result) { + $this->setContent(array_fill(0, self::HEIGHT - 2, '')); + $this->render(); + return; + } + + if (empty($this->result->entries)) { + $this->setContent($this->buildWrappedLines($this->result->lines)); + $this->render(); + return; + } + + $lines = []; + $nextEntryIndex = min($this->revealedEntryCount, count($this->result->entries) - 1); + + foreach ($this->result->entries as $index => $entry) { + if ($index < $this->revealedEntryCount) { + $lines[] = sprintf('%s %s', $entry['label'], $entry['value']); + continue; + } + + if ($index === $nextEntryIndex && ! $this->isRevealComplete) { + $lines[] = $entry['label']; + } + + break; + } + + $this->setContent($this->buildWrappedLines($lines)); + $this->render(); + } + + /** + * Wraps result lines to fit inside the result window. + * + * @param string[] $lines The lines to wrap. + * @return string[] The wrapped and padded lines. + */ + protected function buildWrappedLines(array $lines): array + { + $content = []; + + foreach ($lines as $line) { $content = array_merge($content, explode("\n", wrap_text($line, self::WIDTH - 4))); } $content = array_slice($content, 0, self::HEIGHT - 2); - $content = array_pad($content, self::HEIGHT - 2, ''); - $this->setContent($content); - $this->render(); + return array_pad($content, self::HEIGHT - 2, ''); } } diff --git a/src/Battle/UI/BattleScreen.php b/src/Battle/UI/BattleScreen.php index 1f789cd..9294114 100644 --- a/src/Battle/UI/BattleScreen.php +++ b/src/Battle/UI/BattleScreen.php @@ -3,7 +3,6 @@ namespace Ichiloto\Engine\Battle\UI; use Ichiloto\Engine\Battle\BattlePacing; -use Ichiloto\Engine\Battle\PartyBattlerPositions; use Ichiloto\Engine\Battle\UI\States\BattleScreenState; use Ichiloto\Engine\Battle\UI\States\PlayerActionState; use Ichiloto\Engine\Core\Interfaces\CanRender; @@ -13,14 +12,12 @@ use Ichiloto\Engine\Entities\Interfaces\CharacterInterface; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Troop; +use Ichiloto\Engine\IO\Enumerations\Color; use Ichiloto\Engine\Rendering\Camera; use Ichiloto\Engine\Scenes\Battle\BattleScene; -use Ichiloto\Engine\Scenes\Battle\States\BattleSceneState; -use Ichiloto\Engine\Scenes\Game\GameScene; use Ichiloto\Engine\UI\Windows\BorderPacks\DefaultBorderPack; use Ichiloto\Engine\UI\Windows\Interfaces\BorderPackInterface; use Ichiloto\Engine\Util\Config\ProjectConfig; -use Ichiloto\Engine\Util\Debug; use InvalidArgumentException; use RuntimeException; @@ -117,6 +114,10 @@ class BattleScreen implements CanRender, CanUpdate * @var BattlePacing The active pacing profile. */ protected BattlePacing $pacing; + /** + * @var Color The configured selection highlight color. + */ + protected Color $selectionColor; /** * @var float The time to hide the message. */ @@ -146,10 +147,7 @@ class BattleScreen implements CanRender, CanUpdate */ public function __construct(protected BattleScene $battleScene) { - $leftMargin = intval((get_screen_width() - self::WIDTH) / 2); - $topMargin = 0; - - $this->screenDimensions = new Rect($leftMargin, $topMargin, self::WIDTH, self::HEIGHT); + $this->screenDimensions = $this->resolveScreenDimensions(); $borderPack = config(ProjectConfig::class, 'ui.menu.border', new DefaultBorderPack()); if (! $borderPack instanceof BorderPackInterface) { @@ -158,6 +156,9 @@ public function __construct(protected BattleScene $battleScene) $this->borderPack = $borderPack; $this->pacing = BattlePacing::fromConfig(); + $this->selectionColor = $this->resolveSelectionColor( + config(ProjectConfig::class, 'ui.battle.selection_color', Color::LIGHT_BLUE) + ); $this->alertDuration = $this->pacing->getMessageDurationSeconds(); $this->initializeWindows(); $this->initializeScreenStates(); @@ -346,4 +347,107 @@ protected function initializeScreenStates(): void { $this->playerActionState = new PlayerActionState($this); } + + /** + * Recomputes the battle layout to match the current terminal size. + * + * @return void + */ + public function refreshLayout(): void + { + $this->screenDimensions = $this->resolveScreenDimensions(); + $this->fieldWindow->setPosition($this->getWindowPosition(0, 0)); + $this->messageWindow->setPosition($this->getWindowPosition(2, 1)); + $this->commandWindow->setPosition($this->getWindowPosition(0, $this->fieldWindow->height)); + $this->commandContextWindow->setPosition($this->getWindowPosition($this->commandWindow->width, $this->fieldWindow->height)); + $this->characterNameWindow->setPosition( + $this->getWindowPosition( + $this->commandWindow->width + $this->commandContextWindow->width, + $this->fieldWindow->height + ) + ); + $this->characterStatusWindow->setPosition( + $this->getWindowPosition( + $this->commandWindow->width + $this->commandContextWindow->width + $this->characterNameWindow->width, + $this->fieldWindow->height + ) + ); + } + + /** + * Returns the selection highlight color for battle input windows. + * + * @return Color The configured highlight color. + */ + public function getSelectionColor(): Color + { + return $this->selectionColor; + } + + /** + * Applies battle-selection styling to a line of content. + * + * @param string $text The content line to style. + * @param bool $blink Whether the line should blink to indicate pending input. + * @return string The styled line. + */ + public function styleSelectionLine(string $text, bool $blink = false): string + { + $prefix = $blink ? "\033[5m" : ''; + + return $prefix . $this->selectionColor->value . $text . Color::RESET->value; + } + + /** + * Resolves the configured battle selection color. + * + * @param mixed $configuredColor The configured color value. + * @return Color The resolved color. + */ + protected function resolveSelectionColor(mixed $configuredColor): Color + { + if ($configuredColor instanceof Color) { + return $configuredColor; + } + + if (is_string($configuredColor)) { + $normalizedName = strtoupper(str_replace([' ', '-'], '_', $configuredColor)); + + foreach (Color::cases() as $color) { + if ($color->name === $normalizedName || $color->value === $configuredColor) { + return $color; + } + } + } + + return Color::LIGHT_BLUE; + } + + /** + * Resolves the screen-space frame used to center the battle layout. + * + * @return Rect The centered battle frame. + */ + protected function resolveScreenDimensions(): Rect + { + $leftMargin = max(0, intdiv(get_screen_width() - self::WIDTH, 2)); + $topMargin = max(0, intdiv(get_screen_height() - self::HEIGHT, 2)); + + return new Rect($leftMargin, $topMargin, self::WIDTH, self::HEIGHT); + } + + /** + * Returns a position relative to the centered battle frame. + * + * @param int $xOffset The x offset inside the battle frame. + * @param int $yOffset The y offset inside the battle frame. + * @return \Ichiloto\Engine\Core\Vector2 The resolved window position. + */ + protected function getWindowPosition(int $xOffset, int $yOffset): \Ichiloto\Engine\Core\Vector2 + { + return new \Ichiloto\Engine\Core\Vector2( + $this->screenDimensions->getLeft() + $xOffset, + $this->screenDimensions->getTop() + $yOffset, + ); + } } diff --git a/src/Entities/Actions/SleepAction.php b/src/Entities/Actions/SleepAction.php index 12afece..61c4d97 100644 --- a/src/Entities/Actions/SleepAction.php +++ b/src/Entities/Actions/SleepAction.php @@ -78,7 +78,7 @@ public function execute(ActionContextInterface $context): void $context->player->availableAction = null; $context->player->position->x = $this->trigger->spawnPoint->x; $context->player->position->y = $this->trigger->spawnPoint->y; - $context->player->sprite = $this->trigger->spawnSprite; + $context->player->setFacingSprite($this->trigger->spawnSprite); Console::clear(); $context->scene->mapManager->render(); $context->player->render(); diff --git a/src/Field/Player.php b/src/Field/Player.php index 6ce0561..9c6faf0 100644 --- a/src/Field/Player.php +++ b/src/Field/Player.php @@ -34,21 +34,21 @@ class Player extends GameObject { /** - * @var string $upSprite The sprite of the player when facing up. + * @var string[] $upSprite The sprite of the player when facing up. */ - protected string $upSprite = '^'; + protected array $upSprite = ['^']; /** - * @var string $downSprite The sprite of the player when facing down. + * @var string[] $downSprite The sprite of the player when facing down. */ - protected string $downSprite = 'v'; + protected array $downSprite = ['v']; /** - * @var string $rightSprite The sprite of the player when facing right. + * @var string[] $rightSprite The sprite of the player when facing right. */ - protected string $rightSprite = '>'; + protected array $rightSprite = ['>']; /** - * @var string $leftSprite The sprite of the player when facing left. + * @var string[] $leftSprite The sprite of the player when facing left. */ - protected string $leftSprite = '<'; + protected array $leftSprite = ['<']; /** * @var string $actionSprite The sprite of the player when performing an action. */ @@ -93,8 +93,9 @@ class Player extends GameObject * @param string $name The name of the player. * @param Vector2 $position The position of the player. * @param Rect $shape The shape of the player. - * @param array $sprite The sprite of the player. + * @param string[] $sprite The active sprite of the player. * @param MovementHeading $heading The heading of the player. + * @param array $directionalSprites The configured directional sprite set. */ public function __construct( SceneInterface $scene, @@ -102,7 +103,8 @@ public function __construct( Vector2 $position, Rect $shape, array $sprite, - MovementHeading $heading = MovementHeading::NONE + MovementHeading $heading = MovementHeading::NONE, + array $directionalSprites = [] ) { parent::__construct( @@ -112,7 +114,10 @@ public function __construct( $shape, $sprite ); + + $this->configureDirectionalSprites($directionalSprites); $this->heading = $heading; + $this->setFacingSprite($sprite, $heading); $this->canShowLocationHUDWindow = config(ProjectConfig::class, 'ui.hud.location', false); $this->events = new ItemList(EventTrigger::class); } @@ -284,12 +289,20 @@ public function removeTriggers(): void */ public function updatePlayerSprite(Vector2 $direction): void { - $this->sprite[0] = match (true) { - $direction->y < 0 => $this->upSprite, - $direction->y > 0 => $this->downSprite, - $direction->x < 0 => $this->leftSprite, - $direction->x > 0 => $this->rightSprite, - default => $this->sprite[0], + $this->heading = match (true) { + $direction->y < 0 => MovementHeading::NORTH, + $direction->y > 0 => MovementHeading::SOUTH, + $direction->x < 0 => MovementHeading::WEST, + $direction->x > 0 => MovementHeading::EAST, + default => $this->heading, + }; + + $this->sprite = match ($this->heading) { + MovementHeading::NORTH => $this->upSprite, + MovementHeading::EAST => $this->rightSprite, + MovementHeading::SOUTH => $this->downSprite, + MovementHeading::WEST => $this->leftSprite, + default => $this->sprite, }; } @@ -309,29 +322,76 @@ protected function updatePlayerPosition(Vector2 $direction, Camera $camera): voi $mapManager->render(); } $this->render(); - $this->renderLocationHUDWindow($direction); + $this->renderLocationHUDWindow(); } /** * Renders the location HUD window. * - * @param Vector2 $direction The direction. * @return void */ - protected function renderLocationHUDWindow(Vector2 $direction): void + protected function renderLocationHUDWindow(): void { if ($this->canShowLocationHUDWindow) { - $this->heading = match (true) { - $direction->y < 0 => MovementHeading::NORTH, - $direction->y > 0 => MovementHeading::SOUTH, - $direction->x < 0 => MovementHeading::WEST, - $direction->x > 0 => MovementHeading::EAST, - default => MovementHeading::NONE, - }; $this->getLocationHUDWindow()->updateDetails($this->position, $this->heading); } } + /** + * Sets the active player sprite and synchronizes the heading when possible. + * + * @param string[] $sprite The sprite rows to display. + * @param MovementHeading|null $heading The heading to force, if already known. + * @return void + */ + public function setFacingSprite(array $sprite, ?MovementHeading $heading = null): void + { + $this->sprite = $sprite; + $this->heading = $heading ?? $this->resolveHeadingFromSprite($sprite); + } + + /** + * Applies the configured directional sprite set for movement updates. + * + * @param array $directionalSprites The directional sprite map. + * @return void + */ + protected function configureDirectionalSprites(array $directionalSprites): void + { + if (isset($directionalSprites['north'])) { + $this->upSprite = PlayerSpriteSet::normalizeSprite($directionalSprites['north']); + } + + if (isset($directionalSprites['east'])) { + $this->rightSprite = PlayerSpriteSet::normalizeSprite($directionalSprites['east']); + } + + if (isset($directionalSprites['south'])) { + $this->downSprite = PlayerSpriteSet::normalizeSprite($directionalSprites['south']); + } + + if (isset($directionalSprites['west'])) { + $this->leftSprite = PlayerSpriteSet::normalizeSprite($directionalSprites['west']); + } + } + + /** + * Resolves a heading from the current directional sprite set. + * + * @param string[] $sprite The sprite rows to inspect. + * @return MovementHeading The heading that matches the sprite. + */ + protected function resolveHeadingFromSprite(array $sprite): MovementHeading + { + return match (true) { + $sprite === $this->upSprite => MovementHeading::NORTH, + $sprite === $this->rightSprite => MovementHeading::EAST, + $sprite === $this->downSprite => MovementHeading::SOUTH, + $sprite === $this->leftSprite => MovementHeading::WEST, + default => MovementHeading::NONE, + }; + } + /** * Removes all event triggers. * diff --git a/src/Field/PlayerSpriteSet.php b/src/Field/PlayerSpriteSet.php new file mode 100644 index 0000000..7771d0c --- /dev/null +++ b/src/Field/PlayerSpriteSet.php @@ -0,0 +1,118 @@ +'], + protected(set) array $south = ['v'], + protected(set) array $west = ['<'], + ) + { + } + + /** + * Builds a player sprite set from configuration data. + * + * @param array $data The sprite configuration data. + * @return self The normalized sprite set. + */ + public static function fromArray(array $data): self + { + $sprites = is_array($data['sprites'] ?? null) ? $data['sprites'] : $data; + + return new self( + north: self::normalizeSprite($sprites['north'] ?? ['^']), + east: self::normalizeSprite($sprites['east'] ?? ['>']), + south: self::normalizeSprite($sprites['south'] ?? ['v']), + west: self::normalizeSprite($sprites['west'] ?? ['<']), + ); + } + + /** + * Returns the sprite for the requested heading. + * + * @param MovementHeading $heading The heading to resolve. + * @return string[] The sprite rows for that heading. + */ + public function getSpriteForHeading(MovementHeading $heading): array + { + return match ($heading) { + MovementHeading::NORTH => $this->north, + MovementHeading::EAST => $this->east, + MovementHeading::SOUTH => $this->south, + MovementHeading::WEST => $this->west, + default => $this->south, + }; + } + + /** + * Resolves a heading from a concrete sprite. + * + * @param string[]|string $sprite The sprite rows to inspect. + * @return MovementHeading The heading that owns the sprite, if any. + */ + public function resolveHeading(array|string $sprite): MovementHeading + { + $sprite = self::normalizeSprite($sprite); + + return match (true) { + $sprite === $this->north => MovementHeading::NORTH, + $sprite === $this->east => MovementHeading::EAST, + $sprite === $this->south => MovementHeading::SOUTH, + $sprite === $this->west => MovementHeading::WEST, + default => MovementHeading::NONE, + }; + } + + /** + * Returns the sprite set as plain array data. + * + * @return array{north: string[], east: string[], south: string[], west: string[]} + */ + public function toArray(): array + { + return [ + 'north' => $this->north, + 'east' => $this->east, + 'south' => $this->south, + 'west' => $this->west, + ]; + } + + /** + * Normalizes a configured sprite into a row array. + * + * @param string[]|string $sprite The configured sprite. + * @return string[] The normalized sprite rows. + */ + public static function normalizeSprite(array|string $sprite): array + { + if (is_array($sprite)) { + return array_values(array_map(static fn(mixed $row): string => (string)$row, $sprite)); + } + + return [(string)$sprite]; + } +} diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index fb9c33f..5185947 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -7,6 +7,7 @@ use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\UI\Modal\ModalManager; use Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition; +use ReflectionClass; use RuntimeException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Terminal; @@ -79,11 +80,30 @@ public static function init(Game $game, array $options = [ */ public static function getAvailableSize(): array { - $terminal = new Terminal(); - $width = max(1, $terminal->getWidth() ?: DEFAULT_SCREEN_WIDTH); - $height = max(1, $terminal->getHeight() ?: DEFAULT_SCREEN_HEIGHT); + if ($size = self::readAvailableSizeFromStty()) { + return $size; + } - return ['width' => $width, 'height' => $height]; + if ($size = self::readAvailableSizeFromTput()) { + return $size; + } + + if ($size = self::readAvailableSizeFromSymfonyTerminal()) { + return $size; + } + + $width = getenv('COLUMNS'); + $height = getenv('LINES'); + $size = self::normalizeAvailableSize($width, $height); + + if ($size) { + return $size; + } + + return [ + 'width' => max(1, self::$width ?: DEFAULT_SCREEN_WIDTH), + 'height' => max(1, self::$height ?: DEFAULT_SCREEN_HEIGHT), + ]; } /** @@ -322,6 +342,106 @@ private static function getEmptyBuffer(): array return array_fill(0, self::$height, str_repeat(' ', self::$width)); } + /** + * Attempts to read the current terminal size via stty. + * + * @return array{width: int, height: int}|null The detected size, if available. + */ + protected static function readAvailableSizeFromStty(): ?array + { + $commands = [ + 'stty size < /dev/tty 2>/dev/null', + 'stty size 2>/dev/null', + ]; + + foreach ($commands as $command) { + $size = self::parseSttySizeOutput(shell_exec($command) ?: ''); + + if ($size) { + return $size; + } + } + + return null; + } + + /** + * Attempts to read the current terminal size via tput. + * + * @return array{width: int, height: int}|null The detected size, if available. + */ + protected static function readAvailableSizeFromTput(): ?array + { + $width = shell_exec('tput cols 2>/dev/null'); + $height = shell_exec('tput lines 2>/dev/null'); + + return self::normalizeAvailableSize($width, $height); + } + + /** + * Attempts to read the current terminal size from Symfony's terminal helper. + * + * Symfony caches width and height statically, so those cached values are + * cleared before each probe to keep resize handling live. + * + * @return array{width: int, height: int}|null The detected size, if available. + */ + protected static function readAvailableSizeFromSymfonyTerminal(): ?array + { + try { + $reflection = new ReflectionClass(Terminal::class); + + foreach (['width', 'height'] as $propertyName) { + if (! $reflection->hasProperty($propertyName)) { + continue; + } + + $property = $reflection->getProperty($propertyName); + $property->setValue(null, null); + } + } catch (\Throwable) { + return null; + } + + $terminal = new Terminal(); + + return self::normalizeAvailableSize($terminal->getWidth(), $terminal->getHeight()); + } + + /** + * Parses the output of `stty size`. + * + * @param string $output The raw `stty size` output. + * @return array{width: int, height: int}|null The parsed size, if valid. + */ + protected static function parseSttySizeOutput(string $output): ?array + { + if (! preg_match('/^\s*(\d+)\s+(\d+)\s*$/', trim($output), $matches)) { + return null; + } + + return self::normalizeAvailableSize($matches[2], $matches[1]); + } + + /** + * Normalizes raw terminal size values into positive integer dimensions. + * + * @param mixed $width The raw width. + * @param mixed $height The raw height. + * @return array{width: int, height: int}|null The normalized size, if valid. + */ + protected static function normalizeAvailableSize(mixed $width, mixed $height): ?array + { + if (! is_numeric($width) || ! is_numeric($height)) { + return null; + } + + $width = max(1, intval($width)); + $height = max(1, intval($height)); + + return ['width' => $width, 'height' => $height]; + } + /** * Flushes a single buffered row to the terminal without adding a trailing newline. * diff --git a/src/Scenes/Battle/BattleScene.php b/src/Scenes/Battle/BattleScene.php index 07ddcb1..d860f70 100644 --- a/src/Scenes/Battle/BattleScene.php +++ b/src/Scenes/Battle/BattleScene.php @@ -5,6 +5,7 @@ use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Battle\UI\BattleScreen; use Ichiloto\Engine\Battle\UI\BattleResultWindow; +use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\Troop; use Ichiloto\Engine\Scenes\AbstractScene; @@ -142,6 +143,43 @@ public function update(): void $this->state->execute($this->sceneStateContext); } + /** + * @inheritDoc + */ + #[Override] + public function onScreenResize(int $width, int $height): void + { + parent::onScreenResize($width, $height); + + if (! $this->ui) { + return; + } + + $this->ui->refreshLayout(); + $this->resultWindow?->refreshLayout(); + Console::clear(); + + if ($this->state instanceof BattleStartState) { + return; + } + + if ($this->state instanceof BattleVictoryState || $this->state instanceof BattleDefeatState) { + $this->ui->renderField(); + $this->ui->hideControls(); + + if ($this->resultWindow) { + $this->resultWindow->render(); + } + return; + } + + $this->ui->refresh(); + + if ($this->state instanceof BattlePauseState) { + $this->state->enter(); + } + } + /** * Initializes the battle scene states. * diff --git a/src/Scenes/Battle/States/BattleDefeatState.php b/src/Scenes/Battle/States/BattleDefeatState.php index 7812a25..74fbe90 100644 --- a/src/Scenes/Battle/States/BattleDefeatState.php +++ b/src/Scenes/Battle/States/BattleDefeatState.php @@ -22,7 +22,16 @@ public function enter(): void */ public function execute(?SceneStateContext $context = null): void { + $revealedThisFrame = $this->scene->resultWindow?->update() ?? false; + if (Input::isButtonDown('action')) { + if ($this->scene->resultWindow && ! $this->scene->resultWindow->isComplete()) { + if (! $revealedThisFrame) { + $this->scene->resultWindow->advance(); + } + return; + } + $this->scene->shouldLoadGameOver = true; $this->setState($this->scene->endState); } diff --git a/src/Scenes/Battle/States/BattleVictoryState.php b/src/Scenes/Battle/States/BattleVictoryState.php index 42f1e30..8ede3c4 100644 --- a/src/Scenes/Battle/States/BattleVictoryState.php +++ b/src/Scenes/Battle/States/BattleVictoryState.php @@ -22,7 +22,16 @@ public function enter(): void */ public function execute(?SceneStateContext $context = null): void { + $revealedThisFrame = $this->scene->resultWindow?->update() ?? false; + if (Input::isButtonDown('action')) { + if ($this->scene->resultWindow && ! $this->scene->resultWindow->isComplete()) { + if (! $revealedThisFrame) { + $this->scene->resultWindow->advance(); + } + return; + } + $this->scene->shouldLoadGameOver = false; $this->setState($this->scene->endState); } diff --git a/src/Scenes/Game/GameConfig.php b/src/Scenes/Game/GameConfig.php index 5040ead..6d52928 100644 --- a/src/Scenes/Game/GameConfig.php +++ b/src/Scenes/Game/GameConfig.php @@ -19,12 +19,14 @@ class GameConfig implements SceneConfigurationInterface * GameConfig constructor. * * @param string $mapId The ID of the map. + * @param Party $party The party. * @param Vector2 $playerPosition The position of the player. * @param Rect $playerShape The size of the player. * @param MovementHeading $playerHeading The heading of the player. * @param array $playerStats The stats of the player. * @param array $events The events of the game. * @param array $playerSprite The sprite of the player. + * @param array $playerSprites The directional player sprites. */ public function __construct( protected(set) string $mapId, @@ -35,6 +37,7 @@ public function __construct( protected(set) array $playerStats = [], protected(set) array $events = [], protected(set) array $playerSprite = ['v'], + protected(set) array $playerSprites = [], ) { } @@ -87,28 +90,33 @@ public function __unserialize(array $data): void /** * Returns the data of the game configuration. * - * @return array{mapId: string, playerPosition: Vector2, playerPosition: Vector2, playerHeading: MovementHeading, playerStats: array, events: array, playerSprite: array} + * @return array{mapId: string, playerPosition: Vector2, playerShape: Rect, playerHeading: MovementHeading, playerStats: array, events: array, playerSprite: array, playerSprites: array} */ protected function getData(): array { return [ 'mapId' => $this->mapId, 'playerPosition' => $this->playerPosition, - 'playerSize' => $this->playerShape, + 'playerShape' => $this->playerShape, 'playerHeading' => $this->playerHeading, 'playerStats' => $this->playerStats, 'events' => $this->events, 'playerSprite' => $this->playerSprite, + 'playerSprites' => $this->playerSprites, ]; } /** - * @param array{mapId: string, playerPosition: Vector2, playerHeading: MovementHeading, playerStats: array, events: array} $data + * @param array{mapId: string, playerPosition: Vector2, playerShape?: Rect, playerSize?: Rect, playerHeading: MovementHeading, playerStats: array, events: array, playerSprite?: array, playerSprites?: array} $data * @return void */ protected function setData(array $data): void { foreach ($data as $key => $value) { + if ($key === 'playerSize') { + $key = 'playerShape'; + } + $this->$key = $value; } } @@ -120,4 +128,4 @@ public function jsonSerialize(): array { return $this->getData(); } -} \ No newline at end of file +} diff --git a/src/Scenes/Game/GameLoader.php b/src/Scenes/Game/GameLoader.php index 8d9a715..77aed59 100644 --- a/src/Scenes/Game/GameLoader.php +++ b/src/Scenes/Game/GameLoader.php @@ -10,6 +10,7 @@ use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Entities\PartyLocation; use Ichiloto\Engine\Exceptions\RequiredFieldException; +use Ichiloto\Engine\Field\PlayerSpriteSet; use Ichiloto\Engine\Util\Config\ConfigStore; use Ichiloto\Engine\Util\Stores\ItemStore; use RuntimeException; @@ -89,21 +90,19 @@ public function loadNewGame(): GameConfig $party->inventory->addItems(...$this->itemStore->load($systemData->startingInventory)); $playerPosition = new Vector2($systemData->startingPositions->player->spawnPoint->x, $systemData->startingPositions->player->spawnPoint->y); + $playerSprites = $this->loadPlayerSprites(); + $spawnSprite = PlayerSpriteSet::normalizeSprite($systemData->startingPositions->player->spawnSprite ?? throw new RequiredFieldException('startingPositions.player.spawnSprite')); + return new GameConfig( mapId: $systemData->startingPositions->player->destinationMap, party: $party, playerPosition: $playerPosition, playerShape: new Rect(0, 0, 1, 1), - playerHeading: match($systemData->startingPositions->player->spawnSprite) { - ['^'] => MovementHeading::NORTH, - ['v'] => MovementHeading::SOUTH, - ['<'] => MovementHeading::WEST, - ['>'] => MovementHeading::EAST, - default => MovementHeading::NONE, - }, + playerHeading: $playerSprites->resolveHeading($spawnSprite), playerStats: [], events: [], - playerSprite: $systemData->startingPositions->player->spawnSprite, + playerSprite: $spawnSprite, + playerSprites: $playerSprites->toArray(), ); } @@ -116,6 +115,7 @@ public function loadNewGame(): GameConfig public function loadSavedGame(string $saveFilePath): GameConfig { // Load game data from a saved file + $playerSprites = $this->loadPlayerSprites(); $systemData = new SystemData( 'Last Legend', (object)['name' => 'Gold', 'symbol' => 'G', 'amount' => 1000], @@ -124,7 +124,13 @@ public function loadSavedGame(string $saveFilePath): GameConfig ['item' => 'S-Potion', 'quantity' => 5], ['item' => 'M-Potion', 'quantity' => 3], ], - (object)['player' => (object)['destinationMap' => 'happyville/home', 'spawnPoint' => (object)['x' => 4, 'y' => 5], 'spawnSprite' => ['v']]], + (object)[ + 'player' => (object)[ + 'destinationMap' => 'happyville/home', + 'spawnPoint' => (object)['x' => 4, 'y' => 5], + 'spawnSprite' => $playerSprites->getSpriteForHeading(MovementHeading::SOUTH), + ], + ], ); $party = new Party(); @@ -132,6 +138,9 @@ public function loadSavedGame(string $saveFilePath): GameConfig $party->accountBalance = $systemData->currency->amount; } $party->location = new PartyLocation(); + $spawnSprite = PlayerSpriteSet::normalizeSprite( + $systemData->startingPositions->player->spawnSprite ?? $playerSprites->getSpriteForHeading(MovementHeading::SOUTH) + ); $playerPosition = new Vector2( $systemData->startingPositions->player->spawnPoint->x, $systemData->startingPositions->player->spawnPoint->y @@ -141,10 +150,11 @@ public function loadSavedGame(string $saveFilePath): GameConfig 'party' => $party, 'playerPosition' => $playerPosition, 'playerShape' => new Rect(0, 0, 1, 1), - 'playerHeading' => $systemData->startingPositions->player->heading, + 'playerHeading' => $playerSprites->resolveHeading($spawnSprite), 'playerStats' => [], 'events' => [], - 'playerSprite' => $systemData->startingPositions->player->spawnSprite, + 'playerSprite' => $spawnSprite, + 'playerSprites' => $playerSprites->toArray(), ]; return new GameConfig( @@ -156,6 +166,23 @@ public function loadSavedGame(string $saveFilePath): GameConfig playerStats: $savedData['playerStats'], events: $savedData['events'], playerSprite: $savedData['playerSprite'], + playerSprites: $savedData['playerSprites'], ); } -} \ No newline at end of file + + /** + * Loads the configured player directional sprite set. + * + * @return PlayerSpriteSet The normalized sprite set. + */ + protected function loadPlayerSprites(): PlayerSpriteSet + { + $playerData = asset('Data/Entities/player.php', true); + + if (! is_array($playerData)) { + return new PlayerSpriteSet(); + } + + return PlayerSpriteSet::fromArray($playerData); + } +} diff --git a/src/Scenes/Game/GameScene.php b/src/Scenes/Game/GameScene.php index 36c4764..53b4e8c 100644 --- a/src/Scenes/Game/GameScene.php +++ b/src/Scenes/Game/GameScene.php @@ -147,7 +147,8 @@ public function configure(SceneConfigurationInterface $config): void $this->config->playerPosition, $this->config->playerShape, $this->config->playerSprite, - $this->config->playerHeading + $this->config->playerHeading, + $this->config->playerSprites ); $this->party = $this->config->party; @@ -215,7 +216,7 @@ public function transferPlayer(Location $location): void $this->player->position->x = $location->playerPosition->x; $this->player->position->y = $location->playerPosition->y; if ($location->playerSprite) { - $this->player->sprite = $location->playerSprite; + $this->player->setFacingSprite($location->playerSprite); } $this->loadMap($location->mapFilename, $this->player); $this->player->render(); diff --git a/tests/Unit/BattleCommandWindowTest.php b/tests/Unit/BattleCommandWindowTest.php new file mode 100644 index 0000000..8c51950 --- /dev/null +++ b/tests/Unit/BattleCommandWindowTest.php @@ -0,0 +1,36 @@ +totalCommands = $totalCommands; + } + + public function isBlinkingSelection(): bool + { + return $this->blinkActiveSelection; + } +} + +it('blinks the active command only while the command window is focused', function () { + $window = (new ReflectionClass(BattleCommandWindowTestProxy::class))->newInstanceWithoutConstructor(); + $window->setTotalCommands(4); + + $window->focus(); + + expect($window->activeCommandIndex)->toBe(0) + ->and($window->isBlinkingSelection())->toBeTrue(); + + $window->blur(); + + expect($window->activeCommandIndex)->toBe(-1) + ->and($window->isBlinkingSelection())->toBeFalse(); +}); diff --git a/tests/Unit/BattleResultWindowTest.php b/tests/Unit/BattleResultWindowTest.php new file mode 100644 index 0000000..a0b327c --- /dev/null +++ b/tests/Unit/BattleResultWindowTest.php @@ -0,0 +1,69 @@ +newInstanceWithoutConstructor(); + + $borderPack = $reflection->getProperty('borderPack'); + $borderPack->setAccessible(true); + $borderPack->setValue($screen, new DefaultBorderPack()); + + $screenDimensions = $reflection->getProperty('screenDimensions'); + $screenDimensions->setAccessible(true); + $screenDimensions->setValue($screen, new Rect(0, 0, BattleScreen::WIDTH, BattleScreen::HEIGHT)); + + return $screen; +} + +it('reveals battle rewards sequentially', function () { + $window = new BattleResultWindowTestProxy(makeBattleResultTestScreen()); + $result = new BattleResult( + 'Victory', + entries: [ + ['label' => 'Experience gained:', 'value' => '123'], + ['label' => 'Gold found:', 'value' => '50G'], + ['label' => 'Item drops:', 'value' => 'Potion'], + ], + ); + + $window->display($result); + + expect(implode("\n", $window->getContent()))->toContain('Experience gained:') + ->not->toContain('123') + ->not->toContain('Gold found:') + ->and($window->getHelp())->toBe('enter:Fast Forward'); + + $window->advance(); + + expect(implode("\n", $window->getContent()))->toContain('Experience gained: 123') + ->toContain('Gold found:') + ->not->toContain('50G') + ->and($window->isComplete())->toBeFalse(); + + $window->advance(); + + expect(implode("\n", $window->getContent()))->toContain('Gold found: 50G') + ->toContain('Item drops:') + ->not->toContain('Potion'); + + $window->advance(); + + expect(implode("\n", $window->getContent()))->toContain('Item drops: Potion') + ->and($window->isComplete())->toBeTrue() + ->and($window->getHelp())->toBe('enter:Continue'); +}); diff --git a/tests/Unit/BattleScreenTest.php b/tests/Unit/BattleScreenTest.php new file mode 100644 index 0000000..271669a --- /dev/null +++ b/tests/Unit/BattleScreenTest.php @@ -0,0 +1,34 @@ +newInstanceWithoutConstructor(); + + $selectionColor = $reflection->getProperty('selectionColor'); + $selectionColor->setAccessible(true); + $selectionColor->setValue($screen, Color::LIGHT_BLUE); + + $styledLine = $screen->styleSelectionLine('> Attack'); + + expect($styledLine)->toContain(Color::LIGHT_BLUE->value) + ->and($styledLine)->toContain('> Attack') + ->and($styledLine)->toEndWith(Color::RESET->value); +}); + +it('can blink the active battle selection line', function () { + $reflection = new ReflectionClass(BattleScreen::class); + $screen = $reflection->newInstanceWithoutConstructor(); + + $selectionColor = $reflection->getProperty('selectionColor'); + $selectionColor->setAccessible(true); + $selectionColor->setValue($screen, Color::LIGHT_BLUE); + + $styledLine = $screen->styleSelectionLine('> Kaelion', blink: true); + + expect($styledLine)->toContain("\033[5m") + ->and($styledLine)->toContain(Color::LIGHT_BLUE->value) + ->and($styledLine)->toContain('> Kaelion'); +}); diff --git a/tests/Unit/ConsoleTest.php b/tests/Unit/ConsoleTest.php index b01ca33..c0a7470 100644 --- a/tests/Unit/ConsoleTest.php +++ b/tests/Unit/ConsoleTest.php @@ -3,6 +3,19 @@ use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalText; +class ConsoleTestProxy extends Console +{ + public static function parseSttySize(string $output): ?array + { + return parent::parseSttySizeOutput($output); + } + + public static function normalizeSize(mixed $width, mixed $height): ?array + { + return parent::normalizeAvailableSize($width, $height); + } +} + it('floors float coordinates when writing to the console buffer', function () { $console = new ReflectionClass(Console::class); @@ -31,3 +44,18 @@ expect(TerminalText::stripAnsi($symbols[10] ?? ''))->toBe('Z'); }); + +it('parses stty terminal size output into width and height', function () { + expect(ConsoleTestProxy::parseSttySize("36 170\n"))->toBe([ + 'width' => 170, + 'height' => 36, + ]); +}); + +it('rejects invalid terminal size values during normalization', function () { + expect(ConsoleTestProxy::normalizeSize('abc', 36))->toBeNull() + ->and(ConsoleTestProxy::normalizeSize(0, 0))->toBe([ + 'width' => 1, + 'height' => 1, + ]); +}); diff --git a/tests/Unit/PlayerSpriteSetTest.php b/tests/Unit/PlayerSpriteSetTest.php new file mode 100644 index 0000000..6ba8b69 --- /dev/null +++ b/tests/Unit/PlayerSpriteSetTest.php @@ -0,0 +1,51 @@ + [ + 'north' => '🧍🏽 ', + 'east' => 'πŸšΆπŸ½β€βž‘οΈ', + 'south' => '🧍🏽', + 'west' => 'πŸšΆπŸ½β€', + ], + ]); + + expect($spriteSet->toArray())->toBe([ + 'north' => ['🧍🏽 '], + 'east' => ['πŸšΆπŸ½β€βž‘οΈ'], + 'south' => ['🧍🏽'], + 'west' => ['πŸšΆπŸ½β€'], + ]); +}); + +it('resolves headings from configured sprites', function () { + $spriteSet = new PlayerSpriteSet( + north: ['🧍🏽 '], + east: ['πŸšΆπŸ½β€βž‘οΈ'], + south: ['🧍🏽'], + west: ['πŸšΆπŸ½β€'], + ); + + expect($spriteSet->resolveHeading(['🧍🏽 ']))->toBe(MovementHeading::NORTH) + ->and($spriteSet->resolveHeading(['πŸšΆπŸ½β€βž‘οΈ']))->toBe(MovementHeading::EAST) + ->and($spriteSet->resolveHeading(['🧍🏽']))->toBe(MovementHeading::SOUTH) + ->and($spriteSet->resolveHeading(['πŸšΆπŸ½β€']))->toBe(MovementHeading::WEST); +}); + +it('returns the configured sprite rows for each heading', function () { + $spriteSet = new PlayerSpriteSet( + north: ['north'], + east: ['east'], + south: ['south'], + west: ['west'], + ); + + expect($spriteSet->getSpriteForHeading(MovementHeading::NORTH))->toBe(['north']) + ->and($spriteSet->getSpriteForHeading(MovementHeading::EAST))->toBe(['east']) + ->and($spriteSet->getSpriteForHeading(MovementHeading::SOUTH))->toBe(['south']) + ->and($spriteSet->getSpriteForHeading(MovementHeading::WEST))->toBe(['west']) + ->and($spriteSet->getSpriteForHeading(MovementHeading::NONE))->toBe(['south']); +}); From 36be1ddaa4f0b9672671095f0f6f0add569ac3c4 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 15 Mar 2026 12:37:04 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Core/Game.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Core/Game.php b/src/Core/Game.php index 6c7334a..3255273 100644 --- a/src/Core/Game.php +++ b/src/Core/Game.php @@ -341,6 +341,21 @@ protected function update(): void */ protected function syncScreenSize(): void { + // Throttle expensive terminal size probes to avoid per-frame shell_exec() calls. + // Uses static variables so the throttle state persists across calls without + // requiring additional class properties. + static float $lastProbeTime = 0.0; + static float $minProbeIntervalSeconds = 0.25; // adjust as needed + + $now = microtime(true); + + if ($lastProbeTime !== 0.0 && ($now - $lastProbeTime) < $minProbeIntervalSeconds) { + // Recently probed; skip re-checking the terminal size this frame. + return; + } + + $lastProbeTime = $now; + $availableSize = Console::getAvailableSize(); if ($availableSize['width'] === $this->width && $availableSize['height'] === $this->height) {