diff --git a/AI/BattleAI/AttackPossibility.cpp b/AI/BattleAI/AttackPossibility.cpp index 5e3f4b022..e2fb50dc7 100644 --- a/AI/BattleAI/AttackPossibility.cpp +++ b/AI/BattleAI/AttackPossibility.cpp @@ -201,6 +201,8 @@ int64_t AttackPossibility::evaluateBlockedShootersDmg( if(attackInfo.shooting) return 0; + std::set checkedUnits; + auto attacker = attackInfo.attacker; auto hexes = attacker->getSurroundingHexes(hex); for(BattleHex tile : hexes) @@ -208,9 +210,13 @@ int64_t AttackPossibility::evaluateBlockedShootersDmg( auto st = state->battleGetUnitByPos(tile, true); if(!st || !state->battleMatchOwner(st, attacker)) continue; + if(vstd::contains(checkedUnits, st->unitId())) + continue; if(!state->battleCanShoot(st)) continue; + checkedUnits.insert(st->unitId()); + // FIXME: provide distance info for Jousting bonus BattleAttackInfo rangeAttackInfo(st, attacker, 0, true); rangeAttackInfo.defenderPos = hex; @@ -220,9 +226,10 @@ int64_t AttackPossibility::evaluateBlockedShootersDmg( auto rangeDmg = state->battleEstimateDamage(rangeAttackInfo); auto meleeDmg = state->battleEstimateDamage(meleeAttackInfo); + auto cachedDmg = damageCache.getOriginalDamage(st, attacker, state); int64_t gain = averageDmg(rangeDmg.damage) - averageDmg(meleeDmg.damage) + 1; - res += gain; + res += gain * cachedDmg / std::max(1, averageDmg(rangeDmg.damage)); } return res; diff --git a/AI/BattleAI/BattleAI.cpp b/AI/BattleAI/BattleAI.cpp index 202681bf7..1b4b32732 100644 --- a/AI/BattleAI/BattleAI.cpp +++ b/AI/BattleAI/BattleAI.cpp @@ -23,6 +23,7 @@ #include "../../lib/battle/BattleAction.h" #include "../../lib/battle/BattleStateInfoForRetreat.h" #include "../../lib/battle/CObstacleInstance.h" +#include "../../lib/StartInfo.h" #include "../../lib/CStack.h" // TODO: remove // Eventually only IBattleInfoCallback and battle::Unit should be used, // CUnitState should be private and CStack should be removed completely @@ -122,6 +123,11 @@ static float getStrengthRatio(std::shared_ptr cb, BattleSid return enemy == 0 ? 1.0f : static_cast(our) / enemy; } +int getSimulationTurnsCount(const StartInfo * startInfo) +{ + return startInfo->difficulty < 4 ? 2 : 10; +} + void CBattleAI::activeStack(const BattleID & battleID, const CStack * stack ) { LOG_TRACE_PARAMS(logAi, "stack: %s", stack->nodeName()); @@ -154,7 +160,10 @@ void CBattleAI::activeStack(const BattleID & battleID, const CStack * stack ) logAi->trace("Build evaluator and targets"); #endif - BattleEvaluator evaluator(env, cb, stack, playerID, battleID, side, getStrengthRatio(cb->getBattle(battleID), side)); + BattleEvaluator evaluator( + env, cb, stack, playerID, battleID, side, + getStrengthRatio(cb->getBattle(battleID), side), + getSimulationTurnsCount(env->game()->getStartInfo())); result = evaluator.selectStackAction(stack); diff --git a/AI/BattleAI/BattleEvaluator.cpp b/AI/BattleAI/BattleEvaluator.cpp index 5f9a037dd..0c9941e2b 100644 --- a/AI/BattleAI/BattleEvaluator.cpp +++ b/AI/BattleAI/BattleEvaluator.cpp @@ -49,6 +49,45 @@ SpellTypes spellType(const CSpell * spell) return SpellTypes::OTHER; } +BattleEvaluator::BattleEvaluator( + std::shared_ptr env, + std::shared_ptr cb, + const battle::Unit * activeStack, + PlayerColor playerID, + BattleID battleID, + BattleSide side, + float strengthRatio, + int simulationTurnsCount) + :scoreEvaluator(cb->getBattle(battleID), env, strengthRatio, simulationTurnsCount), + cachedAttack(), playerID(playerID), side(side), env(env), + cb(cb), strengthRatio(strengthRatio), battleID(battleID), simulationTurnsCount(simulationTurnsCount) +{ + hb = std::make_shared(env.get(), cb->getBattle(battleID)); + damageCache.buildDamageCache(hb, side); + + targets = std::make_unique(activeStack, damageCache, hb); + cachedScore = EvaluationResult::INEFFECTIVE_SCORE; +} + +BattleEvaluator::BattleEvaluator( + std::shared_ptr env, + std::shared_ptr cb, + std::shared_ptr hb, + DamageCache & damageCache, + const battle::Unit * activeStack, + PlayerColor playerID, + BattleID battleID, + BattleSide side, + float strengthRatio, + int simulationTurnsCount) + :scoreEvaluator(cb->getBattle(battleID), env, strengthRatio, simulationTurnsCount), + cachedAttack(), playerID(playerID), side(side), env(env), cb(cb), hb(hb), + damageCache(damageCache), strengthRatio(strengthRatio), battleID(battleID), simulationTurnsCount(simulationTurnsCount) +{ + targets = std::make_unique(activeStack, damageCache, hb); + cachedScore = EvaluationResult::INEFFECTIVE_SCORE; +} + std::vector BattleEvaluator::getBrokenWallMoatHexes() const { std::vector result; @@ -167,7 +206,7 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) score ); - if (moveTarget.scorePerTurn <= score) + if (moveTarget.score <= score) { if(evaluationResult.wait) { @@ -197,7 +236,7 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) } //ThreatMap threatsToUs(stack); // These lines may be useful but they are't used in the code. - if(moveTarget.scorePerTurn > score) + if(moveTarget.score > score) { score = moveTarget.score; cachedAttack = moveTarget.cachedAttack; @@ -206,14 +245,13 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) if(stack->waited()) { logAi->debug( - "Moving %s towards hex %s[%d], score: %2f/%2f", + "Moving %s towards hex %s[%d], score: %2f", stack->getDescription(), moveTarget.cachedAttack->attack.defender->getDescription(), moveTarget.cachedAttack->attack.defender->getPosition().hex, - moveTarget.score, - moveTarget.scorePerTurn); + moveTarget.score); - return goTowardsNearest(stack, moveTarget.positions); + return goTowardsNearest(stack, moveTarget.positions, *targets); } else { @@ -235,7 +273,7 @@ BattleAction BattleEvaluator::selectStackAction(const CStack * stack) if(stack->doubleWide() && vstd::contains(brokenWallMoat, stack->getPosition())) return BattleAction::makeMove(stack, stack->getPosition().cloneInDirection(BattleHex::RIGHT)); else - return goTowardsNearest(stack, brokenWallMoat); + return goTowardsNearest(stack, brokenWallMoat, *targets); } } @@ -249,7 +287,32 @@ uint64_t timeElapsed(std::chrono::time_point return std::chrono::duration_cast(end - start).count(); } -BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector hexes) +BattleAction BattleEvaluator::moveOrAttack(const CStack * stack, BattleHex hex, const PotentialTargets & targets) +{ + auto additionalScore = 0; + std::optional attackOnTheWay; + + for(auto & target : targets.possibleAttacks) + { + if(!target.attack.shooting && target.from == hex && target.attackValue() > additionalScore) + { + additionalScore = target.attackValue(); + attackOnTheWay = target; + } + } + + if(attackOnTheWay) + { + activeActionMade = true; + return BattleAction::makeMeleeAttack(stack, attackOnTheWay->attack.defender->getPosition(), attackOnTheWay->from); + } + else + { + return BattleAction::makeMove(stack, hex); + } +} + +BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector hexes, const PotentialTargets & targets) { auto reachability = cb->getBattle(battleID)->getReachability(stack); auto avHexes = cb->getBattle(battleID)->battleGetAvailableHexes(reachability, stack, false); @@ -261,49 +324,38 @@ BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector std::vector targetHexes = hexes; - for(int i = 0; i < 5; i++) - { - std::sort(targetHexes.begin(), targetHexes.end(), [&](BattleHex h1, BattleHex h2) -> bool - { - return reachability.distances[h1] < reachability.distances[h2]; - }); + vstd::erase_if(targetHexes, [](const BattleHex & hex) { return !hex.isValid(); }); - for(auto hex : targetHexes) + std::sort(targetHexes.begin(), targetHexes.end(), [&](BattleHex h1, BattleHex h2) -> bool { - if(vstd::contains(avHexes, hex)) - { - return BattleAction::makeMove(stack, hex); - } - - if(stack->coversPos(hex)) - { - logAi->warn("Warning: already standing on neighbouring tile!"); - //We shouldn't even be here... - return BattleAction::makeDefend(stack); - } - } - - if(reachability.distances[targetHexes.front()] <= GameConstants::BFIELD_SIZE) - { - break; - } - - std::vector copy = targetHexes; - - for(auto hex : copy) - vstd::concatenate(targetHexes, hex.allNeighbouringTiles()); - - vstd::erase_if(targetHexes, [](const BattleHex & hex) {return !hex.isValid();}); - vstd::removeDuplicates(targetHexes); - } + return reachability.distances[h1] < reachability.distances[h2]; + }); BattleHex bestNeighbor = targetHexes.front(); if(reachability.distances[bestNeighbor] > GameConstants::BFIELD_SIZE) { + logAi->trace("No richable hexes."); return BattleAction::makeDefend(stack); } + // this turn + for(auto hex : targetHexes) + { + if(vstd::contains(avHexes, hex)) + { + return moveOrAttack(stack, hex, targets); + } + + if(stack->coversPos(hex)) + { + logAi->warn("Warning: already standing on neighbouring hex!"); + //We shouldn't even be here... + return BattleAction::makeDefend(stack); + } + } + + // not this turn scoreEvaluator.updateReachabilityMap(hb); if(stack->hasBonusOfType(BonusType::FLYING)) @@ -343,7 +395,7 @@ BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector return scoreEvaluator.checkPositionBlocksOurStacks(*hb, stack, hex) ? BLOCKED_STACK_PENALTY + distance : distance; }); - return BattleAction::makeMove(stack, *nearestAvailableHex); + return moveOrAttack(stack, *nearestAvailableHex, targets); } else { @@ -357,11 +409,16 @@ BattleAction BattleEvaluator::goTowardsNearest(const CStack * stack, std::vector if(vstd::contains(avHexes, currentDest) && !scoreEvaluator.checkPositionBlocksOurStacks(*hb, stack, currentDest)) - return BattleAction::makeMove(stack, currentDest); + { + return moveOrAttack(stack, currentDest, targets); + } currentDest = reachability.predecessors[currentDest]; } } + + logAi->error("We should either detect that hexes are unreachable or make a move!"); + return BattleAction::makeDefend(stack); } bool BattleEvaluator::canCastSpell() @@ -597,7 +654,7 @@ bool BattleEvaluator::attemptCastingSpell(const CStack * activeStack) #endif PotentialTargets innerTargets(activeStack, innerCache, state); - BattleExchangeEvaluator innerEvaluator(state, env, strengthRatio); + BattleExchangeEvaluator innerEvaluator(state, env, strengthRatio, simulationTurnsCount); if(!innerTargets.possibleAttacks.empty()) { diff --git a/AI/BattleAI/BattleEvaluator.h b/AI/BattleAI/BattleEvaluator.h index 1c71c45f6..f5af918aa 100644 --- a/AI/BattleAI/BattleEvaluator.h +++ b/AI/BattleAI/BattleEvaluator.h @@ -37,16 +37,18 @@ class BattleEvaluator float cachedScore; DamageCache damageCache; float strengthRatio; + int simulationTurnsCount; public: BattleAction selectStackAction(const CStack * stack); bool attemptCastingSpell(const CStack * stack); bool canCastSpell(); std::optional findBestCreatureSpell(const CStack * stack); - BattleAction goTowardsNearest(const CStack * stack, std::vector hexes); + BattleAction goTowardsNearest(const CStack * stack, std::vector hexes, const PotentialTargets & targets); std::vector getBrokenWallMoatHexes() const; void evaluateCreatureSpellcast(const CStack * stack, PossibleSpellcast & ps); //for offensive damaging spells only void print(const std::string & text) const; + BattleAction moveOrAttack(const CStack * stack, BattleHex hex, const PotentialTargets & targets); BattleEvaluator( std::shared_ptr env, @@ -55,15 +57,8 @@ public: PlayerColor playerID, BattleID battleID, BattleSide side, - float strengthRatio) - :scoreEvaluator(cb->getBattle(battleID), env, strengthRatio), cachedAttack(), playerID(playerID), side(side), env(env), cb(cb), strengthRatio(strengthRatio), battleID(battleID) - { - hb = std::make_shared(env.get(), cb->getBattle(battleID)); - damageCache.buildDamageCache(hb, side); - - targets = std::make_unique(activeStack, damageCache, hb); - cachedScore = EvaluationResult::INEFFECTIVE_SCORE; - } + float strengthRatio, + int simulationTurnsCount); BattleEvaluator( std::shared_ptr env, @@ -74,10 +69,6 @@ public: PlayerColor playerID, BattleID battleID, BattleSide side, - float strengthRatio) - :scoreEvaluator(cb->getBattle(battleID), env, strengthRatio), cachedAttack(), playerID(playerID), side(side), env(env), cb(cb), hb(hb), damageCache(damageCache), strengthRatio(strengthRatio), battleID(battleID) - { - targets = std::make_unique(activeStack, damageCache, hb); - cachedScore = EvaluationResult::INEFFECTIVE_SCORE; - } + float strengthRatio, + int simulationTurnsCount); }; diff --git a/AI/BattleAI/BattleExchangeVariant.cpp b/AI/BattleAI/BattleExchangeVariant.cpp index cf7316d43..f0073e72d 100644 --- a/AI/BattleAI/BattleExchangeVariant.cpp +++ b/AI/BattleAI/BattleExchangeVariant.cpp @@ -18,7 +18,7 @@ AttackerValue::AttackerValue() } MoveTarget::MoveTarget() - : positions(), cachedAttack(), score(EvaluationResult::INEFFECTIVE_SCORE), scorePerTurn(EvaluationResult::INEFFECTIVE_SCORE) + : positions(), cachedAttack(), score(EvaluationResult::INEFFECTIVE_SCORE) { turnsToRich = 1; } @@ -42,7 +42,7 @@ float BattleExchangeVariant::trackAttack( for(auto affectedUnit : affectedUnits) { auto unitToUpdate = hb->getForUpdate(affectedUnit->unitId()); - auto damageDealt = unitToUpdate->getTotalHealth() - affectedUnit->getTotalHealth(); + auto damageDealt = unitToUpdate->getAvailableHealth() - affectedUnit->getAvailableHealth(); if(damageDealt > 0) { @@ -58,7 +58,7 @@ float BattleExchangeVariant::trackAttack( #if BATTLE_TRACE_LEVEL>=1 logAi->trace( "%s -> %s, ap retaliation, %s, dps: %lld", - ap.attack.defender->getDescription(), + hb->getForUpdate(ap.attack.defender->unitId())->getDescription(), ap.attack.attacker->getDescription(), ap.attack.shooting ? "shot" : "mellee", damageDealt); @@ -277,6 +277,36 @@ EvaluationResult BattleExchangeEvaluator::findBestTarget( return result; } +ReachabilityInfo getReachabilityWithEnemyBypass( + const battle::Unit * activeStack, + DamageCache & damageCache, + std::shared_ptr state) +{ + ReachabilityInfo::Parameters params(activeStack, activeStack->getPosition()); + + if(!params.flying) + { + for(const auto * unit : state->battleAliveUnits()) + { + if(unit->unitSide() == activeStack->unitSide()) + continue; + + auto dmg = damageCache.getOriginalDamage(activeStack, unit, state); + auto turnsToKill = unit->getAvailableHealth() / dmg; + + vstd::amin(turnsToKill, 100); + + for(auto & hex : unit->getHexes()) + if(hex.isAvailable()) //towers can have <0 pos; we don't also want to overwrite side columns + params.destructibleEnemyTurns[hex] = turnsToKill * unit->getMovementRange(); + } + + params.bypassEnemyStacks = true; + } + + return state->getReachability(params); +} + MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable( const battle::Unit * activeStack, PotentialTargets & targets, @@ -286,6 +316,8 @@ MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable( MoveTarget result; BattleExchangeVariant ev; + logAi->trace("Find move towards unreachable. Enemies count %d", targets.unreachableEnemies.size()); + if(targets.unreachableEnemies.empty()) return result; @@ -296,17 +328,17 @@ MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable( updateReachabilityMap(hb); - auto dists = cb->getReachability(activeStack); + auto dists = getReachabilityWithEnemyBypass(activeStack, damageCache, hb); + auto flying = activeStack->hasBonusOfType(BonusType::FLYING); for(const battle::Unit * enemy : targets.unreachableEnemies) { - std::vector adjacentStacks = getAdjacentUnits(enemy); - auto closestStack = *vstd::minElementByFun(adjacentStacks, [&](const battle::Unit * u) -> int64_t - { - return dists.distToNearestNeighbour(activeStack, u) * 100000 - activeStack->getTotalHealth(); - }); + logAi->trace( + "Checking movement towards %d of %s", + enemy->getCount(), + enemy->creatureId().toCreature()->getNameSingularTranslated()); - auto distance = dists.distToNearestNeighbour(activeStack, closestStack); + auto distance = dists.distToNearestNeighbour(activeStack, enemy); if(distance >= GameConstants::BFIELD_SIZE) continue; @@ -315,30 +347,84 @@ MoveTarget BattleExchangeEvaluator::findMoveTowardsUnreachable( continue; auto turnsToRich = (distance - 1) / speed + 1; - auto hexes = closestStack->getSurroundingHexes(); - auto enemySpeed = closestStack->getMovementRange(); + auto hexes = enemy->getSurroundingHexes(); + auto enemySpeed = enemy->getMovementRange(); auto speedRatio = speed / static_cast(enemySpeed); auto multiplier = speedRatio > 1 ? 1 : speedRatio; - if(enemy->canShoot()) - multiplier *= 1.5f; - - for(auto hex : hexes) + for(auto & hex : hexes) { // FIXME: provide distance info for Jousting bonus - auto bai = BattleAttackInfo(activeStack, closestStack, 0, cb->battleCanShoot(activeStack)); + auto bai = BattleAttackInfo(activeStack, enemy, 0, cb->battleCanShoot(activeStack)); auto attack = AttackPossibility::evaluate(bai, hex, damageCache, hb); attack.shootersBlockedDmg = 0; // we do not want to count on it, it is not for sure auto score = calculateExchange(attack, turnsToRich, targets, damageCache, hb); - auto scorePerTurn = BattleScore(score.enemyDamageReduce * std::sqrt(multiplier / turnsToRich), score.ourDamageReduce); - if(result.scorePerTurn < scoreValue(scorePerTurn)) + score.enemyDamageReduce *= multiplier; + +#if BATTLE_TRACE_LEVEL >= 1 + logAi->trace("Multiplier: %f, turns: %d, current score %f, new score %f", multiplier, turnsToRich, result.score, scoreValue(score)); +#endif + + if(result.score < scoreValue(score) + || (result.turnsToRich > turnsToRich && vstd::isAlmostEqual(result.score, scoreValue(score)))) { - result.scorePerTurn = scoreValue(scorePerTurn); result.score = scoreValue(score); - result.positions = closestStack->getAttackableHexes(activeStack); + result.positions.clear(); + +#if BATTLE_TRACE_LEVEL >= 1 + logAi->trace("New high score"); +#endif + + for(BattleHex enemyHex : enemy->getAttackableHexes(activeStack)) + { + while(!flying && dists.distances[enemyHex] > speed) + { + enemyHex = dists.predecessors.at(enemyHex); + if(dists.accessibility[enemyHex] == EAccessibility::ALIVE_STACK) + { + auto defenderToBypass = hb->battleGetUnitByPos(enemyHex); + + if(defenderToBypass) + { +#if BATTLE_TRACE_LEVEL >= 1 + logAi->trace("Found target to bypass at %d", enemyHex.hex); +#endif + + auto attackHex = dists.predecessors[enemyHex]; + auto baiBypass = BattleAttackInfo(activeStack, defenderToBypass, 0, cb->battleCanShoot(activeStack)); + auto attackBypass = AttackPossibility::evaluate(baiBypass, attackHex, damageCache, hb); + + auto adjacentStacks = getAdjacentUnits(enemy); + + adjacentStacks.push_back(defenderToBypass); + vstd::removeDuplicates(adjacentStacks); + + auto bypassScore = calculateExchange( + attackBypass, + dists.distances[attackHex], + targets, + damageCache, + hb, + adjacentStacks); + + if(scoreValue(bypassScore) > result.score) + { + result.score = scoreValue(bypassScore); + +#if BATTLE_TRACE_LEVEL >= 1 + logAi->trace("New high score after bypass %f", scoreValue(bypassScore)); +#endif + } + } + } + } + + result.positions.push_back(enemyHex); + } + result.cachedAttack = attack; result.turnsToRich = turnsToRich; } @@ -382,7 +468,8 @@ ReachabilityData BattleExchangeEvaluator::getExchangeUnits( const AttackPossibility & ap, uint8_t turn, PotentialTargets & targets, - std::shared_ptr hb) const + std::shared_ptr hb, + std::vector additionalUnits) const { ReachabilityData result; @@ -390,13 +477,26 @@ ReachabilityData BattleExchangeEvaluator::getExchangeUnits( if(!ap.attack.shooting) hexes.push_back(ap.from); - std::vector allReachableUnits; - + std::vector allReachableUnits = additionalUnits; + for(auto hex : hexes) { vstd::concatenate(allReachableUnits, turn == 0 ? reachabilityMap.at(hex) : getOneTurnReachableUnits(turn, hex)); } + for(auto hex : ap.attack.attacker->getHexes()) + { + auto unitsReachingAttacker = turn == 0 ? reachabilityMap.at(hex) : getOneTurnReachableUnits(turn, hex); + for(auto unit : unitsReachingAttacker) + { + if(unit->unitSide() != ap.attack.attacker->unitSide()) + { + allReachableUnits.push_back(unit); + result.enemyUnitsReachingAttacker.insert(unit->unitId()); + } + } + } + vstd::removeDuplicates(allReachableUnits); auto copy = allReachableUnits; @@ -432,7 +532,7 @@ ReachabilityData BattleExchangeEvaluator::getExchangeUnits( for(auto unit : allReachableUnits) { - auto accessible = !unit->canShoot(); + auto accessible = !unit->canShoot() || vstd::contains(additionalUnits, unit); if(!accessible) { @@ -456,14 +556,14 @@ ReachabilityData BattleExchangeEvaluator::getExchangeUnits( for(auto unit : turnOrder[turn]) { if(vstd::contains(allReachableUnits, unit)) - result.units.push_back(unit); + result.units[turn].push_back(unit); } - } - vstd::erase_if(result.units, [&](const battle::Unit * u) -> bool - { - return !hb->battleGetUnitByID(u->unitId())->alive(); - }); + vstd::erase_if(result.units[turn], [&](const battle::Unit * u) -> bool + { + return !hb->battleGetUnitByID(u->unitId())->alive(); + }); + } return result; } @@ -494,7 +594,8 @@ BattleScore BattleExchangeEvaluator::calculateExchange( uint8_t turn, PotentialTargets & targets, DamageCache & damageCache, - std::shared_ptr hb) const + std::shared_ptr hb, + std::vector additionalUnits) const { #if BATTLE_TRACE_LEVEL>=1 logAi->trace("Battle exchange at %d", ap.attack.shooting ? ap.dest.hex : ap.from.hex); @@ -513,7 +614,7 @@ BattleScore BattleExchangeEvaluator::calculateExchange( if(hb->battleGetUnitByID(ap.attack.defender->unitId())->alive()) enemyStacks.push_back(ap.attack.defender); - ReachabilityData exchangeUnits = getExchangeUnits(ap, turn, targets, hb); + ReachabilityData exchangeUnits = getExchangeUnits(ap, turn, targets, hb, additionalUnits); if(exchangeUnits.units.empty()) { @@ -523,22 +624,25 @@ BattleScore BattleExchangeEvaluator::calculateExchange( auto exchangeBattle = std::make_shared(env.get(), hb); BattleExchangeVariant v; - for(auto unit : exchangeUnits.units) + for(int exchangeTurn = 0; exchangeTurn < exchangeUnits.units.size(); exchangeTurn++) { - if(unit->isTurret()) - continue; - - bool isOur = exchangeBattle->battleMatchOwner(ap.attack.attacker, unit, true); - auto & attackerQueue = isOur ? ourStacks : enemyStacks; - auto u = exchangeBattle->getForUpdate(unit->unitId()); - - if(u->alive() && !vstd::contains(attackerQueue, unit)) + for(auto unit : exchangeUnits.units.at(exchangeTurn)) { - attackerQueue.push_back(unit); + if(unit->isTurret()) + continue; + + bool isOur = exchangeBattle->battleMatchOwner(ap.attack.attacker, unit, true); + auto & attackerQueue = isOur ? ourStacks : enemyStacks; + auto u = exchangeBattle->getForUpdate(unit->unitId()); + + if(u->alive() && !vstd::contains(attackerQueue, unit)) + { + attackerQueue.push_back(unit); #if BATTLE_TRACE_LEVEL - logAi->trace("Exchanging: %s", u->getDescription()); + logAi->trace("Exchanging: %s", u->getDescription()); #endif + } } } @@ -552,122 +656,166 @@ BattleScore BattleExchangeEvaluator::calculateExchange( bool canUseAp = true; - for(auto activeUnit : exchangeUnits.units) + std::set blockedShooters; + + int totalTurnsCount = simulationTurnsCount >= turn + turnOrder.size() + ? simulationTurnsCount + : turn + turnOrder.size(); + + for(int exchangeTurn = 0; exchangeTurn < simulationTurnsCount; exchangeTurn++) { - bool isOur = exchangeBattle->battleMatchOwner(ap.attack.attacker, activeUnit, true); - battle::Units & attackerQueue = isOur ? ourStacks : enemyStacks; - battle::Units & oppositeQueue = isOur ? enemyStacks : ourStacks; + bool isMovingTurm = exchangeTurn < turn; + int queueTurn = exchangeTurn >= exchangeUnits.units.size() + ? exchangeUnits.units.size() - 1 + : exchangeTurn; - auto attacker = exchangeBattle->getForUpdate(activeUnit->unitId()); - - if(!attacker->alive()) + for(auto activeUnit : exchangeUnits.units.at(queueTurn)) { + bool isOur = exchangeBattle->battleMatchOwner(ap.attack.attacker, activeUnit, true); + battle::Units & attackerQueue = isOur ? ourStacks : enemyStacks; + battle::Units & oppositeQueue = isOur ? enemyStacks : ourStacks; + + auto attacker = exchangeBattle->getForUpdate(activeUnit->unitId()); + auto shooting = exchangeBattle->battleCanShoot(attacker.get()) + && !vstd::contains(blockedShooters, attacker->unitId()); + + if(!attacker->alive()) + { #if BATTLE_TRACE_LEVEL>=1 - logAi->trace( "Attacker is dead"); + logAi->trace("Attacker is dead"); #endif - continue; - } - - auto targetUnit = ap.attack.defender; - - if(!isOur || !exchangeBattle->battleGetUnitByID(targetUnit->unitId())->alive()) - { - auto estimateAttack = [&](const battle::Unit * u) -> float - { - auto stackWithBonuses = exchangeBattle->getForUpdate(u->unitId()); - auto score = v.trackAttack( - attacker, - stackWithBonuses, - exchangeBattle->battleCanShoot(stackWithBonuses.get()), - isOur, - damageCache, - hb, - true); - -#if BATTLE_TRACE_LEVEL>=1 - logAi->trace("Best target selector %s->%s score = %2f", attacker->getDescription(), stackWithBonuses->getDescription(), score); -#endif - - return score; - }; - - auto unitsInOppositeQueueExceptInaccessible = oppositeQueue; - - vstd::erase_if(unitsInOppositeQueueExceptInaccessible, [&](const battle::Unit * u)->bool - { - return vstd::contains(exchangeUnits.shooters, u); - }); - - if(!unitsInOppositeQueueExceptInaccessible.empty()) - { - targetUnit = *vstd::maxElementByFun(unitsInOppositeQueueExceptInaccessible, estimateAttack); + continue; } - else + + if(isMovingTurm && !shooting + && !vstd::contains(exchangeUnits.enemyUnitsReachingAttacker, attacker->unitId())) { - auto reachable = exchangeBattle->battleGetUnitsIf([this, &exchangeBattle, &attacker](const battle::Unit * u) -> bool +#if BATTLE_TRACE_LEVEL>=1 + logAi->trace("Attacker is moving"); +#endif + + continue; + } + + auto targetUnit = ap.attack.defender; + + if(!isOur || !exchangeBattle->battleGetUnitByID(targetUnit->unitId())->alive()) + { +#if BATTLE_TRACE_LEVEL>=2 + logAi->trace("Best target selector for %s", attacker->getDescription()); +#endif + auto estimateAttack = [&](const battle::Unit * u) -> float + { + auto stackWithBonuses = exchangeBattle->getForUpdate(u->unitId()); + auto score = v.trackAttack( + attacker, + stackWithBonuses, + exchangeBattle->battleCanShoot(stackWithBonuses.get()), + isOur, + damageCache, + hb, + true); + +#if BATTLE_TRACE_LEVEL>=2 + logAi->trace("Best target selector %s->%s score = %2f", attacker->getDescription(), stackWithBonuses->getDescription(), score); +#endif + + return score; + }; + + auto unitsInOppositeQueueExceptInaccessible = oppositeQueue; + + vstd::erase_if(unitsInOppositeQueueExceptInaccessible, [&](const battle::Unit * u)->bool { - if(u->unitSide() == attacker->unitSide()) - return false; - - if(!exchangeBattle->getForUpdate(u->unitId())->alive()) - return false; - - if (!u->getPosition().isValid()) - return false; // e.g. tower shooters - - return vstd::contains_if(reachabilityMap.at(u->getPosition()), [&attacker](const battle::Unit * other) -> bool - { - return attacker->unitId() == other->unitId(); - }); + return vstd::contains(exchangeUnits.shooters, u); }); - if(!reachable.empty()) + if(!isOur + && exchangeTurn == 0 + && exchangeUnits.units.at(exchangeTurn).at(0)->unitId() != ap.attack.attacker->unitId() + && !vstd::contains(exchangeUnits.enemyUnitsReachingAttacker, attacker->unitId())) { - targetUnit = *vstd::maxElementByFun(reachable, estimateAttack); + vstd::erase_if(unitsInOppositeQueueExceptInaccessible, [&](const battle::Unit * u) -> bool + { + return u->unitId() == ap.attack.attacker->unitId(); + }); + } + + if(!unitsInOppositeQueueExceptInaccessible.empty()) + { + targetUnit = *vstd::maxElementByFun(unitsInOppositeQueueExceptInaccessible, estimateAttack); } else { + auto reachable = exchangeBattle->battleGetUnitsIf([this, &exchangeBattle, &attacker](const battle::Unit * u) -> bool + { + if(u->unitSide() == attacker->unitSide()) + return false; + + if(!exchangeBattle->getForUpdate(u->unitId())->alive()) + return false; + + if(!u->getPosition().isValid()) + return false; // e.g. tower shooters + + return vstd::contains_if(reachabilityMap.at(u->getPosition()), [&attacker](const battle::Unit * other) -> bool + { + return attacker->unitId() == other->unitId(); + }); + }); + + if(!reachable.empty()) + { + targetUnit = *vstd::maxElementByFun(reachable, estimateAttack); + } + else + { #if BATTLE_TRACE_LEVEL>=1 - logAi->trace("Battle queue is empty and no reachable enemy."); + logAi->trace("Battle queue is empty and no reachable enemy."); #endif - continue; + continue; + } } } - } - auto defender = exchangeBattle->getForUpdate(targetUnit->unitId()); - auto shooting = exchangeBattle->battleCanShoot(attacker.get()); - const int totalAttacks = attacker->getTotalAttacks(shooting); + auto defender = exchangeBattle->getForUpdate(targetUnit->unitId()); + const int totalAttacks = attacker->getTotalAttacks(shooting); - if(canUseAp && activeUnit->unitId() == ap.attack.attacker->unitId() - && targetUnit->unitId() == ap.attack.defender->unitId()) - { - v.trackAttack(ap, exchangeBattle, damageCache); - } - else - { - for(int i = 0; i < totalAttacks; i++) + if(canUseAp && activeUnit->unitId() == ap.attack.attacker->unitId() + && targetUnit->unitId() == ap.attack.defender->unitId()) { - v.trackAttack(attacker, defender, shooting, isOur, damageCache, exchangeBattle); - - if(!attacker->alive() || !defender->alive()) - break; + v.trackAttack(ap, exchangeBattle, damageCache); } + else + { + for(int i = 0; i < totalAttacks; i++) + { + v.trackAttack(attacker, defender, shooting, isOur, damageCache, exchangeBattle); + + if(!attacker->alive() || !defender->alive()) + break; + } + } + + if(!shooting) + blockedShooters.insert(defender->unitId()); + + canUseAp = false; + + vstd::erase_if(attackerQueue, [&](const battle::Unit * u) -> bool + { + return !exchangeBattle->battleGetUnitByID(u->unitId())->alive(); + }); + + vstd::erase_if(oppositeQueue, [&](const battle::Unit * u) -> bool + { + return !exchangeBattle->battleGetUnitByID(u->unitId())->alive(); + }); } - canUseAp = false; - - vstd::erase_if(attackerQueue, [&](const battle::Unit * u) -> bool - { - return !exchangeBattle->battleGetUnitByID(u->unitId())->alive(); - }); - - vstd::erase_if(oppositeQueue, [&](const battle::Unit * u) -> bool - { - return !exchangeBattle->battleGetUnitByID(u->unitId())->alive(); - }); + exchangeBattle->nextRound(); } // avoid blocking path for stronger stack by weaker stack @@ -679,11 +827,28 @@ BattleScore BattleExchangeEvaluator::calculateExchange( for(auto hex : hexes) reachabilityMap[hex] = getOneTurnReachableUnits(turn, hex); + auto score = v.getScore(); + + if(simulationTurnsCount < totalTurnsCount) + { + float scalingRatio = simulationTurnsCount / static_cast(totalTurnsCount); + + score.enemyDamageReduce *= scalingRatio; + score.ourDamageReduce *= scalingRatio; + } + + if(turn > 0) + { + auto turnMultiplier = 1 - std::min(0.2, 0.05 * turn); + + score.enemyDamageReduce *= turnMultiplier; + } + #if BATTLE_TRACE_LEVEL>=1 - logAi->trace("Exchange score: enemy: %2f, our -%2f", v.getScore().enemyDamageReduce, v.getScore().ourDamageReduce); + logAi->trace("Exchange score: enemy: %2f, our -%2f", score.enemyDamageReduce, score.ourDamageReduce); #endif - return v.getScore(); + return score; } bool BattleExchangeEvaluator::canBeHitThisTurn(const AttackPossibility & ap) diff --git a/AI/BattleAI/BattleExchangeVariant.h b/AI/BattleAI/BattleExchangeVariant.h index 728d163bc..7ba3886df 100644 --- a/AI/BattleAI/BattleExchangeVariant.h +++ b/AI/BattleAI/BattleExchangeVariant.h @@ -54,7 +54,6 @@ struct AttackerValue struct MoveTarget { float score; - float scorePerTurn; std::vector positions; std::optional cachedAttack; uint8_t turnsToRich; @@ -64,7 +63,7 @@ struct MoveTarget struct EvaluationResult { - static const int64_t INEFFECTIVE_SCORE = -10000; + static const int64_t INEFFECTIVE_SCORE = -100000000; AttackPossibility bestAttack; MoveTarget bestMove; @@ -113,13 +112,15 @@ private: struct ReachabilityData { - std::vector units; + std::map> units; // shooters which are within mellee attack and mellee units std::vector melleeAccessible; // far shooters std::vector shooters; + + std::set enemyUnitsReachingAttacker; }; class BattleExchangeEvaluator @@ -131,6 +132,7 @@ private: std::map> reachabilityMap; std::vector turnOrder; float negativeEffectMultiplier; + int simulationTurnsCount; float scoreValue(const BattleScore & score) const; @@ -139,7 +141,8 @@ private: uint8_t turn, PotentialTargets & targets, DamageCache & damageCache, - std::shared_ptr hb) const; + std::shared_ptr hb, + std::vector additionalUnits = {}) const; bool canBeHitThisTurn(const AttackPossibility & ap); @@ -147,7 +150,8 @@ public: BattleExchangeEvaluator( std::shared_ptr cb, std::shared_ptr env, - float strengthRatio): cb(cb), env(env) { + float strengthRatio, + int simulationTurnsCount): cb(cb), env(env), simulationTurnsCount(simulationTurnsCount){ negativeEffectMultiplier = strengthRatio >= 1 ? 1 : strengthRatio * strengthRatio; } @@ -171,7 +175,8 @@ public: const AttackPossibility & ap, uint8_t turn, PotentialTargets & targets, - std::shared_ptr hb) const; + std::shared_ptr hb, + std::vector additionalUnits = {}) const; bool checkPositionBlocksOurStacks(HypotheticBattle & hb, const battle::Unit * unit, BattleHex position); diff --git a/AI/Nullkiller/AIGateway.cpp b/AI/Nullkiller/AIGateway.cpp index 1a105d5f8..e66722d09 100644 --- a/AI/Nullkiller/AIGateway.cpp +++ b/AI/Nullkiller/AIGateway.cpp @@ -570,6 +570,7 @@ void AIGateway::initGameInterface(std::shared_ptr env, std::shared_ LOG_TRACE(logAi); myCb = CB; cbc = CB; + this->env = env; NET_EVENT_HANDLER; playerID = *myCb->getPlayerID(); diff --git a/AI/Nullkiller/Analyzers/DangerHitMapAnalyzer.cpp b/AI/Nullkiller/Analyzers/DangerHitMapAnalyzer.cpp index 27bfe5dd0..c17cb9c3b 100644 --- a/AI/Nullkiller/Analyzers/DangerHitMapAnalyzer.cpp +++ b/AI/Nullkiller/Analyzers/DangerHitMapAnalyzer.cpp @@ -89,13 +89,12 @@ void DangerHitMapAnalyzer::updateHitMap() heroes[hero->tempOwner][hero] = HeroRole::MAIN; } - if (obj->ID == Obj::TOWN) + if(obj->ID == Obj::TOWN) { - auto town = dynamic_cast(obj); - auto hero = town->garrisonHero; + auto town = dynamic_cast(obj); - if(hero) - heroes[hero->tempOwner][hero] = HeroRole::MAIN; + if(town->garrisonHero) + heroes[town->garrisonHero->tempOwner][town->garrisonHero] = HeroRole::MAIN; } } diff --git a/CI/android-32/before_install.sh b/CI/android-32/before_install.sh index 12adadd89..67cacaddf 100755 --- a/CI/android-32/before_install.sh +++ b/CI/android-32/before_install.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -DEPS_FILENAME=armeabi-v7a +DEPS_FILENAME=dependencies-android-32 . CI/android/before_install.sh diff --git a/CI/android-64/before_install.sh b/CI/android-64/before_install.sh index b26082d29..af0a36874 100755 --- a/CI/android-64/before_install.sh +++ b/CI/android-64/before_install.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -DEPS_FILENAME=aarch64-v8a +DEPS_FILENAME=dependencies-android-64 . CI/android/before_install.sh diff --git a/CI/android/before_install.sh b/CI/android/before_install.sh index 8a13382d7..146d52110 100755 --- a/CI/android/before_install.sh +++ b/CI/android/before_install.sh @@ -4,6 +4,4 @@ echo "ANDROID_NDK_ROOT=$ANDROID_HOME/ndk/25.2.9519653" >> $GITHUB_ENV brew install ninja -mkdir ~/.conan ; cd ~/.conan -curl -L "https://github.com/vcmi/vcmi-dependencies/releases/download/android-1.1/$DEPS_FILENAME.txz" \ - | tar -xf - --xz +. CI/install_conan_dependencies.sh "$DEPS_FILENAME" \ No newline at end of file diff --git a/CI/install_conan_dependencies.sh b/CI/install_conan_dependencies.sh new file mode 100644 index 000000000..2b14811d0 --- /dev/null +++ b/CI/install_conan_dependencies.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +RELEASE_TAG="1.2" +FILENAME="$1" +DOWNLOAD_URL="https://github.com/vcmi/vcmi-dependencies/releases/download/$RELEASE_TAG/$FILENAME.txz" + +mkdir ~/.conan +cd ~/.conan +curl -L "$DOWNLOAD_URL" | tar -xf - --xz diff --git a/CI/ios/before_install.sh b/CI/ios/before_install.sh index 350730cb4..a8326e1fd 100755 --- a/CI/ios/before_install.sh +++ b/CI/ios/before_install.sh @@ -2,6 +2,4 @@ echo DEVELOPER_DIR=/Applications/Xcode_14.2.app >> $GITHUB_ENV -mkdir ~/.conan ; cd ~/.conan -curl -L 'https://github.com/vcmi/vcmi-ios-deps/releases/download/1.2.1/ios-arm64.txz' \ - | tar -xf - +. CI/install_conan_dependencies.sh "dependencies-ios" diff --git a/CI/mac-arm/before_install.sh b/CI/mac-arm/before_install.sh index c90b6a1b1..41701758b 100755 --- a/CI/mac-arm/before_install.sh +++ b/CI/mac-arm/before_install.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -DEPS_FILENAME=intel-cross-arm +DEPS_FILENAME=dependencies-mac-arm . CI/mac/before_install.sh diff --git a/CI/mac-intel/before_install.sh b/CI/mac-intel/before_install.sh index fcbcea328..a96955b20 100755 --- a/CI/mac-intel/before_install.sh +++ b/CI/mac-intel/before_install.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -DEPS_FILENAME=intel +DEPS_FILENAME=dependencies-mac-intel . CI/mac/before_install.sh diff --git a/CI/mac/before_install.sh b/CI/mac/before_install.sh index e1f53b145..ed11e87df 100755 --- a/CI/mac/before_install.sh +++ b/CI/mac/before_install.sh @@ -4,6 +4,4 @@ echo DEVELOPER_DIR=/Applications/Xcode_14.2.app >> $GITHUB_ENV brew install ninja -mkdir ~/.conan ; cd ~/.conan -curl -L "https://github.com/vcmi/vcmi-deps-macos/releases/download/1.2.1/$DEPS_FILENAME.txz" \ - | tar -xf - +. CI/install_conan_dependencies.sh "$DEPS_FILENAME" diff --git a/CI/mingw-32/before_install.sh b/CI/mingw-32/before_install.sh index 3b41ed86d..857f4a716 100644 --- a/CI/mingw-32/before_install.sh +++ b/CI/mingw-32/before_install.sh @@ -11,6 +11,4 @@ curl -O -L http://mirrors.kernel.org/ubuntu/pool/universe/m/mingw-w64/mingw-w64- curl -O -L http://mirrors.kernel.org/ubuntu/pool/universe/m/mingw-w64/mingw-w64-i686-dev_10.0.0-3_all.deb \ && sudo dpkg -i mingw-w64-i686-dev_10.0.0-3_all.deb; -mkdir ~/.conan ; cd ~/.conan -curl -L "https://github.com/vcmi/vcmi-deps-windows-conan/releases/download/1.2/vcmi-deps-windows-conan-w32.tgz" \ - | tar -xzf - +. CI/install_conan_dependencies.sh "dependencies-mingw-32" diff --git a/CI/mingw/before_install.sh b/CI/mingw/before_install.sh index 09318d85b..70fbaf0d5 100755 --- a/CI/mingw/before_install.sh +++ b/CI/mingw/before_install.sh @@ -11,6 +11,4 @@ curl -O -L http://mirrors.kernel.org/ubuntu/pool/universe/m/mingw-w64/mingw-w64- curl -O -L http://mirrors.kernel.org/ubuntu/pool/universe/m/mingw-w64/mingw-w64-x86-64-dev_10.0.0-3_all.deb \ && sudo dpkg -i mingw-w64-x86-64-dev_10.0.0-3_all.deb; -mkdir ~/.conan ; cd ~/.conan -curl -L "https://github.com/vcmi/vcmi-deps-windows-conan/releases/download/1.2/vcmi-deps-windows-conan-w64.tgz" \ - | tar -xzf - +. CI/install_conan_dependencies.sh "dependencies-mingw" diff --git a/Mods/vcmi/config/vcmi/english.json b/Mods/vcmi/config/vcmi/english.json index 517e7984d..8db122468 100644 --- a/Mods/vcmi/config/vcmi/english.json +++ b/Mods/vcmi/config/vcmi/english.json @@ -162,6 +162,38 @@ "vcmi.systemOptions.otherGroup" : "Other Settings", // unused right now "vcmi.systemOptions.townsGroup" : "Town Screen", + "vcmi.statisticWindow.statistics" : "Statistics", + "vcmi.statisticWindow.tsvCopy" : "Data to clipboard", + "vcmi.statisticWindow.selectView" : "Select view", + "vcmi.statisticWindow.value" : "Value", + "vcmi.statisticWindow.title.overview" : "Overview", + "vcmi.statisticWindow.title.resources" : "Resources", + "vcmi.statisticWindow.title.income" : "Income", + "vcmi.statisticWindow.title.numberOfHeroes" : "No. of heroes", + "vcmi.statisticWindow.title.numberOfTowns" : "No. of towns", + "vcmi.statisticWindow.title.numberOfArtifacts" : "No. of artifacts", + "vcmi.statisticWindow.title.numberOfDwellings" : "No. of dwellings", + "vcmi.statisticWindow.title.numberOfMines" : "No. of mines", + "vcmi.statisticWindow.title.armyStrength" : "Army strength", + "vcmi.statisticWindow.title.experience" : "Experience", + "vcmi.statisticWindow.title.resourcesSpentArmy" : "Army costs", + "vcmi.statisticWindow.title.resourcesSpentBuildings" : "Building costs", + "vcmi.statisticWindow.title.mapExplored" : "Map explore ratio", + "vcmi.statisticWindow.param.playerName" : "Player name", + "vcmi.statisticWindow.param.daysSurvived" : "Days survived", + "vcmi.statisticWindow.param.maxHeroLevel" : "Max hero level", + "vcmi.statisticWindow.param.battleWinRatioHero" : "Win ratio (vs. hero)", + "vcmi.statisticWindow.param.battleWinRatioNeutral" : "Win ratio (vs. neutral)", + "vcmi.statisticWindow.param.battlesHero" : "Battles (vs. hero)", + "vcmi.statisticWindow.param.battlesNeutral" : "Battles (vs. neutral)", + "vcmi.statisticWindow.param.maxArmyStrength" : "Max total army strength", + "vcmi.statisticWindow.param.tradeVolume" : "Trade volume", + "vcmi.statisticWindow.param.obeliskVisited" : "Obelisk visited", + "vcmi.statisticWindow.icon.townCaptured" : "Town captured", + "vcmi.statisticWindow.icon.strongestHeroDefeated" : "Strongest hero of opponent defeated", + "vcmi.statisticWindow.icon.grailFound" : "Grail found", + "vcmi.statisticWindow.icon.defeated" : "Defeated", + "vcmi.systemOptions.fullscreenBorderless.hover" : "Fullscreen (borderless)", "vcmi.systemOptions.fullscreenBorderless.help" : "{Borderless Fullscreen}\n\nIf selected, VCMI will run in borderless fullscreen mode. In this mode, game will always use same resolution as desktop, ignoring selected resolution.", "vcmi.systemOptions.fullscreenExclusive.hover" : "Fullscreen (exclusive)", diff --git a/Mods/vcmi/config/vcmi/german.json b/Mods/vcmi/config/vcmi/german.json index 64d606147..aad6d8d11 100644 --- a/Mods/vcmi/config/vcmi/german.json +++ b/Mods/vcmi/config/vcmi/german.json @@ -162,6 +162,38 @@ "vcmi.systemOptions.otherGroup" : "Andere Einstellungen", // unused right now "vcmi.systemOptions.townsGroup" : "Stadt-Bildschirm", + "vcmi.statisticWindow.statistics" : "Statistik", + "vcmi.statisticWindow.tsvCopy" : "Daten in Zwischenabl.", + "vcmi.statisticWindow.selectView" : "Ansicht wählen", + "vcmi.statisticWindow.value" : "Wert", + "vcmi.statisticWindow.title.overview" : "Überblick", + "vcmi.statisticWindow.title.resources" : "Ressourcen", + "vcmi.statisticWindow.title.income" : "Einkommen", + "vcmi.statisticWindow.title.numberOfHeroes" : "Nr. der Helden", + "vcmi.statisticWindow.title.numberOfTowns" : "Nr. der Städte", + "vcmi.statisticWindow.title.numberOfArtifacts" : "Nr. der Artefakte", + "vcmi.statisticWindow.title.numberOfDwellings" : "Nr. der Behausungen", + "vcmi.statisticWindow.title.numberOfMines" : "Nr. der Minen", + "vcmi.statisticWindow.title.armyStrength" : "Armeestärke", + "vcmi.statisticWindow.title.experience" : "Erfahrung", + "vcmi.statisticWindow.title.resourcesSpentArmy" : "Armeekosten", + "vcmi.statisticWindow.title.resourcesSpentBuildings" : "Gebäudekosten", + "vcmi.statisticWindow.title.mapExplored" : "Maperkundungsrate", + "vcmi.statisticWindow.param.playerName" : "Spielername", + "vcmi.statisticWindow.param.daysSurvived" : "Tage überlebt", + "vcmi.statisticWindow.param.maxHeroLevel" : "Max Heldenlevel", + "vcmi.statisticWindow.param.battleWinRatioHero" : "Sieg Verh. (Helden)", + "vcmi.statisticWindow.param.battleWinRatioNeutral" : "Sieg Verh. (Neutral)", + "vcmi.statisticWindow.param.battlesHero" : "Kämpfe (Helden)", + "vcmi.statisticWindow.param.battlesNeutral" : "Kämpfe (Neutral)", + "vcmi.statisticWindow.param.maxArmyStrength" : "Max Gesamt-Armeestärke", + "vcmi.statisticWindow.param.tradeVolume" : "Handelsvol.", + "vcmi.statisticWindow.param.obeliskVisited" : "Obelisk besucht", + "vcmi.statisticWindow.icon.townCaptured" : "Stadt erobert", + "vcmi.statisticWindow.icon.strongestHeroDefeated" : "Stärksten Helden eines Gegners besiegt", + "vcmi.statisticWindow.icon.grailFound" : "Gral gefunden", + "vcmi.statisticWindow.icon.defeated" : "Besiegt", + "vcmi.systemOptions.fullscreenBorderless.hover" : "Vollbild (randlos)", "vcmi.systemOptions.fullscreenBorderless.help" : "{Randloses Vollbild}\n\nWenn diese Option ausgewählt ist, wird VCMI im randlosen Vollbildmodus ausgeführt. In diesem Modus wird das Spiel immer dieselbe Auflösung wie der Desktop verwenden und die gewählte Auflösung ignorieren.", "vcmi.systemOptions.fullscreenExclusive.hover" : "Vollbild (exklusiv)", diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 67750193b..563e8a0ef 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -65,6 +65,7 @@ set(client_SRCS mainmenu/CPrologEpilogVideo.cpp mainmenu/CreditsScreen.cpp mainmenu/CHighScoreScreen.cpp + mainmenu/CStatisticScreen.cpp mapView/MapRenderer.cpp mapView/MapRendererContext.cpp @@ -260,6 +261,7 @@ set(client_HEADERS mainmenu/CPrologEpilogVideo.h mainmenu/CreditsScreen.h mainmenu/CHighScoreScreen.h + mainmenu/CStatisticScreen.h mapView/IMapRendererContext.h mapView/IMapRendererObserver.h diff --git a/client/CServerHandler.cpp b/client/CServerHandler.cpp index 66aca56a3..3fbeb2a35 100644 --- a/client/CServerHandler.cpp +++ b/client/CServerHandler.cpp @@ -673,13 +673,13 @@ void CServerHandler::startGameplay(VCMI_LIB_WRAP_NAMESPACE(CGameState) * gameSta setState(EClientState::GAMEPLAY); } -void CServerHandler::showHighScoresAndEndGameplay(PlayerColor player, bool victory) +void CServerHandler::showHighScoresAndEndGameplay(PlayerColor player, bool victory, const StatisticDataSet & statistic) { HighScoreParameter param = HighScore::prepareHighScores(client->gameState(), player, victory); if(victory && client->gameState()->getStartInfo()->campState) { - startCampaignScenario(param, client->gameState()->getStartInfo()->campState); + startCampaignScenario(param, client->gameState()->getStartInfo()->campState, statistic); } else { @@ -689,7 +689,7 @@ void CServerHandler::showHighScoresAndEndGameplay(PlayerColor player, bool victo endGameplay(); CMM->menu->switchToTab("main"); - GH.windows().createAndPushWindow(victory, scenarioHighScores); + GH.windows().createAndPushWindow(victory, scenarioHighScores, statistic); } } @@ -722,7 +722,7 @@ void CServerHandler::restartGameplay() logicConnection->enterLobbyConnectionMode(); } -void CServerHandler::startCampaignScenario(HighScoreParameter param, std::shared_ptr cs) +void CServerHandler::startCampaignScenario(HighScoreParameter param, std::shared_ptr cs, const StatisticDataSet & statistic) { std::shared_ptr ourCampaign = cs; @@ -738,7 +738,7 @@ void CServerHandler::startCampaignScenario(HighScoreParameter param, std::shared endGameplay(); auto & epilogue = ourCampaign->scenario(*ourCampaign->lastScenario()).epilog; - auto finisher = [ourCampaign, campaignScoreCalculator]() + auto finisher = [ourCampaign, campaignScoreCalculator, statistic]() { if(ourCampaign->campaignSet != "" && ourCampaign->isCampaignFinished()) { @@ -754,7 +754,7 @@ void CServerHandler::startCampaignScenario(HighScoreParameter param, std::shared else { CMM->openCampaignScreen(ourCampaign->campaignSet); - GH.windows().createAndPushWindow(true, *campaignScoreCalculator); + GH.windows().createAndPushWindow(true, *campaignScoreCalculator, statistic); } }; diff --git a/client/CServerHandler.h b/client/CServerHandler.h index ace71bd68..30c5106ca 100644 --- a/client/CServerHandler.h +++ b/client/CServerHandler.h @@ -13,6 +13,7 @@ #include "../lib/network/NetworkInterface.h" #include "../lib/StartInfo.h" +#include "../lib/gameState/GameStatistics.h" VCMI_LIB_NAMESPACE_BEGIN @@ -204,11 +205,11 @@ public: void debugStartTest(std::string filename, bool save = false); void startGameplay(VCMI_LIB_WRAP_NAMESPACE(CGameState) * gameState = nullptr); - void showHighScoresAndEndGameplay(PlayerColor player, bool victory); + void showHighScoresAndEndGameplay(PlayerColor player, bool victory, const StatisticDataSet & statistic); void endNetwork(); void endGameplay(); void restartGameplay(); - void startCampaignScenario(HighScoreParameter param, std::shared_ptr cs = {}); + void startCampaignScenario(HighScoreParameter param, std::shared_ptr cs, const StatisticDataSet & statistic); void showServerError(const std::string & txt) const; // TODO: LobbyState must be updated within game so we should always know how many player interfaces our client handle diff --git a/client/NetPacksClient.cpp b/client/NetPacksClient.cpp index c4379e3d7..05f6fe038 100644 --- a/client/NetPacksClient.cpp +++ b/client/NetPacksClient.cpp @@ -420,7 +420,7 @@ void ApplyClientNetPackVisitor::visitPlayerEndsGame(PlayerEndsGame & pack) adventureInt.reset(); } - CSH->showHighScoresAndEndGameplay(pack.player, pack.victoryLossCheckResult.victory()); + CSH->showHighScoresAndEndGameplay(pack.player, pack.victoryLossCheckResult.victory(), pack.statistic); } // In auto testing pack.mode we always close client if red pack.player won or lose diff --git a/client/eventsSDL/InputHandler.cpp b/client/eventsSDL/InputHandler.cpp index 52d3776a2..00a60c402 100644 --- a/client/eventsSDL/InputHandler.cpp +++ b/client/eventsSDL/InputHandler.cpp @@ -32,6 +32,7 @@ #include #include +#include InputHandler::InputHandler() : enableMouse(settings["input"]["enableMouse"].Bool()) @@ -142,6 +143,11 @@ InputMode InputHandler::getCurrentInputMode() return currentInputMode; } +void InputHandler::copyToClipBoard(const std::string & text) +{ + SDL_SetClipboardText(text.c_str()); +} + std::vector InputHandler::acquireEvents() { boost::unique_lock lock(eventsMutex); diff --git a/client/eventsSDL/InputHandler.h b/client/eventsSDL/InputHandler.h index 0b930498f..71d58c0d4 100644 --- a/client/eventsSDL/InputHandler.h +++ b/client/eventsSDL/InputHandler.h @@ -103,4 +103,6 @@ public: bool isKeyboardShiftDown() const; InputMode getCurrentInputMode(); + + void copyToClipBoard(const std::string & text); }; diff --git a/client/gui/CIntObject.cpp b/client/gui/CIntObject.cpp index cc9890123..7dc4dff85 100644 --- a/client/gui/CIntObject.cpp +++ b/client/gui/CIntObject.cpp @@ -156,12 +156,17 @@ void CIntObject::setRedrawParent(bool on) } void CIntObject::fitToScreen(int borderWidth, bool propagate) +{ + fitToRect(Rect(Point(0, 0), GH.screenDimensions()), borderWidth, propagate); +} + +void CIntObject::fitToRect(Rect rect, int borderWidth, bool propagate) { Point newPos = pos.topLeft(); - vstd::amax(newPos.x, borderWidth); - vstd::amax(newPos.y, borderWidth); - vstd::amin(newPos.x, GH.screenDimensions().x - borderWidth - pos.w); - vstd::amin(newPos.y, GH.screenDimensions().y - borderWidth - pos.h); + vstd::amax(newPos.x, rect.x + borderWidth); + vstd::amax(newPos.y, rect.y + borderWidth); + vstd::amin(newPos.x, rect.x + rect.w - borderWidth - pos.w); + vstd::amin(newPos.y, rect.y + rect.h - borderWidth - pos.h); if (newPos != pos.topLeft()) moveTo(newPos, propagate); } diff --git a/client/gui/CIntObject.h b/client/gui/CIntObject.h index b6cfd7fd8..da3ebcdc8 100644 --- a/client/gui/CIntObject.h +++ b/client/gui/CIntObject.h @@ -122,6 +122,7 @@ public: const Rect & center(const Point &p, bool propagate = true); //moves object so that point p will be in its center const Rect & center(bool propagate = true); //centers when pos.w and pos.h are set, returns new position void fitToScreen(int borderWidth, bool propagate = true); //moves window to fit into screen + void fitToRect(Rect rect, int borderWidth, bool propagate = true); //moves window to fit into rect void moveBy(const Point &p, bool propagate = true); void moveTo(const Point &p, bool propagate = true);//move this to new position, coordinates are absolute (0,0 is topleft screen corner) diff --git a/client/gui/Shortcut.h b/client/gui/Shortcut.h index e4c52384d..66019bd77 100644 --- a/client/gui/Shortcut.h +++ b/client/gui/Shortcut.h @@ -74,6 +74,7 @@ enum class EShortcut HIGH_SCORES_CAMPAIGNS, HIGH_SCORES_SCENARIOS, HIGH_SCORES_RESET, + HIGH_SCORES_STATISTICS, // Game lobby / scenario selection LOBBY_BEGIN_STANDARD_GAME, // b diff --git a/client/gui/ShortcutHandler.cpp b/client/gui/ShortcutHandler.cpp index fc986084b..a4cffb8c4 100644 --- a/client/gui/ShortcutHandler.cpp +++ b/client/gui/ShortcutHandler.cpp @@ -290,6 +290,7 @@ EShortcut ShortcutHandler::findShortcut(const std::string & identifier ) const {"highScoresCampaigns", EShortcut::HIGH_SCORES_CAMPAIGNS }, {"highScoresScenarios", EShortcut::HIGH_SCORES_SCENARIOS }, {"highScoresReset", EShortcut::HIGH_SCORES_RESET }, + {"highScoresStatistics", EShortcut::HIGH_SCORES_STATISTICS }, {"lobbyReplayVideo", EShortcut::LOBBY_REPLAY_VIDEO }, {"lobbyExtraOptions", EShortcut::LOBBY_EXTRA_OPTIONS }, {"lobbyTurnOptions", EShortcut::LOBBY_TURN_OPTIONS }, diff --git a/client/mainmenu/CHighScoreScreen.cpp b/client/mainmenu/CHighScoreScreen.cpp index 5d9db7fa4..6676dcb1c 100644 --- a/client/mainmenu/CHighScoreScreen.cpp +++ b/client/mainmenu/CHighScoreScreen.cpp @@ -11,6 +11,7 @@ #include "StdInc.h" #include "CHighScoreScreen.h" +#include "CStatisticScreen.h" #include "../gui/CGuiHandler.h" #include "../gui/WindowHandler.h" #include "../gui/Shortcut.h" @@ -33,6 +34,7 @@ #include "../../lib/CCreatureHandler.h" #include "../../lib/constants/EntityIdentifiers.h" #include "../../lib/gameState/HighScore.h" +#include "../../lib/gameState/GameStatistics.h" CHighScoreScreen::CHighScoreScreen(HighScorePage highscorepage, int highlighted) : CWindowObject(BORDERED), highscorepage(highscorepage), highlighted(highlighted) @@ -170,8 +172,8 @@ void CHighScoreScreen::buttonExitClick() close(); } -CHighScoreInputScreen::CHighScoreInputScreen(bool won, HighScoreCalculation calc) - : CWindowObject(BORDERED), won(won), calc(calc) +CHighScoreInputScreen::CHighScoreInputScreen(bool won, HighScoreCalculation calc, const StatisticDataSet & statistic) + : CWindowObject(BORDERED), won(won), calc(calc), stat(statistic) { addUsedEvents(LCLICK | KEYBOARD); @@ -204,6 +206,12 @@ CHighScoreInputScreen::CHighScoreInputScreen(bool won, HighScoreCalculation calc videoPlayer = std::make_shared(Point(0, 0), VideoPath::builtin("LOSEGAME.SMK"), true, [this](){close();}); CCS->musich->playMusic(AudioPath::builtin("music/UltimateLose"), false, true); } + + if (settings["general"]["enableUiEnhancements"].Bool()) + { + statisticButton = std::make_shared(Point(726, 10), AnimationPath::builtin("TPTAV02.DEF"), CButton::tooltip(CGI->generaltexth->translate("vcmi.statisticWindow.statistics")), [this](){ GH.windows().createAndPushWindow(stat); }, EShortcut::HIGH_SCORES_STATISTICS); + texts.push_back(std::make_shared(716, 25, EFonts::FONT_HIGH_SCORE, ETextAlignment::CENTERRIGHT, Colors::WHITE, CGI->generaltexth->translate("vcmi.statisticWindow.statistics") + ":")); + } } int CHighScoreInputScreen::addEntry(std::string text) { @@ -253,6 +261,9 @@ void CHighScoreInputScreen::show(Canvas & to) void CHighScoreInputScreen::clickPressed(const Point & cursorPosition) { + if(statisticButton->pos.isInside(cursorPosition)) + return; + OBJECT_CONSTRUCTION; if(!won) @@ -280,6 +291,8 @@ void CHighScoreInputScreen::clickPressed(const Point & cursorPosition) void CHighScoreInputScreen::keyPressed(EShortcut key) { + if(key == EShortcut::HIGH_SCORES_STATISTICS) // ignore shortcut for skipping video with key + return; clickPressed(Point()); } diff --git a/client/mainmenu/CHighScoreScreen.h b/client/mainmenu/CHighScoreScreen.h index 93f79e42d..dbb9aa60c 100644 --- a/client/mainmenu/CHighScoreScreen.h +++ b/client/mainmenu/CHighScoreScreen.h @@ -10,6 +10,7 @@ #pragma once #include "../windows/CWindowObject.h" #include "../../lib/gameState/HighScore.h" +#include "../../lib/gameState/GameStatistics.h" class CButton; class CLabel; @@ -70,16 +71,19 @@ public: class CHighScoreInputScreen : public CWindowObject { - std::vector> texts; + std::vector> texts; std::shared_ptr input; std::shared_ptr background; std::shared_ptr videoPlayer; std::shared_ptr backgroundAroundMenu; + std::shared_ptr statisticButton; + bool won; HighScoreCalculation calc; + StatisticDataSet stat; public: - CHighScoreInputScreen(bool won, HighScoreCalculation calc); + CHighScoreInputScreen(bool won, HighScoreCalculation calc, const StatisticDataSet & statistic); int addEntry(std::string text); diff --git a/client/mainmenu/CStatisticScreen.cpp b/client/mainmenu/CStatisticScreen.cpp new file mode 100644 index 000000000..37812d340 --- /dev/null +++ b/client/mainmenu/CStatisticScreen.cpp @@ -0,0 +1,521 @@ +/* + * CStatisticScreen.cpp, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ + +#include "StdInc.h" + +#include "CStatisticScreen.h" +#include "../CGameInfo.h" + +#include "../gui/CGuiHandler.h" +#include "../gui/WindowHandler.h" +#include "../eventsSDL/InputHandler.h" +#include "../gui/Shortcut.h" + +#include "../render/Graphics.h" +#include "../render/IImage.h" +#include "../render/IRenderHandler.h" + +#include "../widgets/ComboBox.h" +#include "../widgets/Images.h" +#include "../widgets/GraphicalPrimitiveCanvas.h" +#include "../widgets/TextControls.h" +#include "../widgets/Buttons.h" +#include "../windows/InfoWindows.h" +#include "../widgets/Slider.h" + +#include "../../lib/gameState/GameStatistics.h" +#include "../../lib/gameState/CGameState.h" +#include "../../lib/texts/CGeneralTextHandler.h" +#include "../../lib/texts/TextOperations.h" + +#include + +std::string CStatisticScreen::getDay(int d) +{ + return std::to_string(CGameState::getDate(d, Date::MONTH)) + "/" + std::to_string(CGameState::getDate(d, Date::WEEK)) + "/" + std::to_string(CGameState::getDate(d, Date::DAY_OF_WEEK)); +} + +CStatisticScreen::CStatisticScreen(const StatisticDataSet & stat) + : CWindowObject(BORDERED), statistic(stat) +{ + OBJECT_CONSTRUCTION; + pos = center(Rect(0, 0, 800, 600)); + filledBackground = std::make_shared(ImagePath::builtin("DiBoxBck"), Rect(0, 0, pos.w, pos.h)); + filledBackground->setPlayerColor(PlayerColor(1)); + + contentArea = Rect(10, 40, 780, 510); + layout.emplace_back(std::make_shared(400, 20, FONT_BIG, ETextAlignment::CENTER, Colors::YELLOW, CGI->generaltexth->translate("vcmi.statisticWindow.statistics"))); + layout.emplace_back(std::make_shared(contentArea, ColorRGBA(0, 0, 0, 128), ColorRGBA(64, 80, 128, 255), 1)); + layout.emplace_back(std::make_shared(Point(725, 558), AnimationPath::builtin("MUBCHCK"), CButton::tooltip(), [this](){ close(); }, EShortcut::GLOBAL_ACCEPT)); + + buttonSelect = std::make_shared(Point(10, 564), AnimationPath::builtin("GSPBUT2"), CButton::tooltip(), [this](bool on){ onSelectButton(); }); + buttonSelect->setTextOverlay(CGI->generaltexth->translate("vcmi.statisticWindow.selectView"), EFonts::FONT_SMALL, Colors::YELLOW); + + buttonCsvSave = std::make_shared(Point(150, 564), AnimationPath::builtin("GSPBUT2"), CButton::tooltip(), [this](bool on){ GH.input().copyToClipBoard(statistic.toCsv("\t")); }); + buttonCsvSave->setTextOverlay(CGI->generaltexth->translate("vcmi.statisticWindow.tsvCopy"), EFonts::FONT_SMALL, Colors::YELLOW); + + mainContent = getContent(OVERVIEW, EGameResID::NONE); +} + +void CStatisticScreen::onSelectButton() +{ + std::vector texts; + for(auto & val : contentInfo) + texts.emplace_back(CGI->generaltexth->translate(std::get<0>(val.second))); + GH.windows().createAndPushWindow(texts, [this](int selectedIndex) + { + OBJECT_CONSTRUCTION; + if(!std::get<1>(contentInfo[static_cast(selectedIndex)])) + mainContent = getContent(static_cast(selectedIndex), EGameResID::NONE); + else + { + auto content = static_cast(selectedIndex); + auto possibleRes = std::vector{EGameResID::GOLD, EGameResID::WOOD, EGameResID::MERCURY, EGameResID::ORE, EGameResID::SULFUR, EGameResID::CRYSTAL, EGameResID::GEMS}; + std::vector resourceText; + for(const auto & res : possibleRes) + resourceText.emplace_back(CGI->generaltexth->translate(TextIdentifier("core.restypes", res.getNum()).get())); + + GH.windows().createAndPushWindow(resourceText, [this, content, possibleRes](int index) + { + OBJECT_CONSTRUCTION; + mainContent = getContent(content, possibleRes[index]); + }); + } + }); +} + +TData CStatisticScreen::extractData(const StatisticDataSet & stat, const ExtractFunctor & selector) const +{ + auto tmpData = stat.data; + std::sort(tmpData.begin(), tmpData.end(), [](const StatisticDataSetEntry & v1, const StatisticDataSetEntry & v2){ return v1.player == v2.player ? v1.day < v2.day : v1.player < v2.player; }); + + PlayerColor tmpColor = PlayerColor::NEUTRAL; + std::vector tmpColorSet; + TData plotData; + EPlayerStatus statusLastRound = EPlayerStatus::INGAME; + for(const auto & val : tmpData) + { + if(tmpColor != val.player) + { + if(tmpColorSet.size()) + { + plotData.push_back({graphics->playerColors[tmpColor.getNum()], std::vector(tmpColorSet)}); + tmpColorSet.clear(); + } + + tmpColor = val.player; + } + if(val.status == EPlayerStatus::INGAME || (statusLastRound == EPlayerStatus::INGAME && val.status == EPlayerStatus::LOSER)) + tmpColorSet.emplace_back(selector(val)); + statusLastRound = val.status; //to keep at least one dataset after loose + } + if(tmpColorSet.size()) + plotData.push_back({graphics->playerColors[tmpColor.getNum()], std::vector(tmpColorSet)}); + + return plotData; +} + +TIcons CStatisticScreen::extractIcons() const +{ + TIcons icons; + + auto tmpData = statistic.data; + std::sort(tmpData.begin(), tmpData.end(), [](const StatisticDataSetEntry & v1, const StatisticDataSetEntry & v2){ return v1.player == v2.player ? v1.day < v2.day : v1.player < v2.player; }); + + auto imageTown = GH.renderHandler().loadImage(AnimationPath::builtin("cradvntr"), 3, 0, EImageBlitMode::COLORKEY); + imageTown->scaleTo(Point(CHART_ICON_SIZE, CHART_ICON_SIZE)); + auto imageBattle = GH.renderHandler().loadImage(AnimationPath::builtin("cradvntr"), 5, 0, EImageBlitMode::COLORKEY); + imageBattle->scaleTo(Point(CHART_ICON_SIZE, CHART_ICON_SIZE)); + auto imageDefeated = GH.renderHandler().loadImage(AnimationPath::builtin("tpthchk"), 1, 0, EImageBlitMode::COLORKEY); + imageDefeated->scaleTo(Point(CHART_ICON_SIZE, CHART_ICON_SIZE)); + auto imageGrail = GH.renderHandler().loadImage(AnimationPath::builtin("vwsymbol"), 2, 0, EImageBlitMode::COLORKEY); + imageGrail->scaleTo(Point(CHART_ICON_SIZE, CHART_ICON_SIZE)); + + std::map foundDefeated; + std::map foundGrail; + + for(const auto & val : tmpData) + { + if(val.eventCapturedTown) + icons.push_back({ graphics->playerColors[val.player], val.day, imageTown, CGI->generaltexth->translate("vcmi.statisticWindow.icon.townCaptured") }); + if(val.eventDefeatedStrongestHero) + icons.push_back({ graphics->playerColors[val.player], val.day, imageBattle, CGI->generaltexth->translate("vcmi.statisticWindow.icon.strongestHeroDefeated") }); + if(val.status == EPlayerStatus::LOSER && !foundDefeated[val.player]) + { + foundDefeated[val.player] = true; + icons.push_back({ graphics->playerColors[val.player], val.day, imageDefeated, CGI->generaltexth->translate("vcmi.statisticWindow.icon.defeated") }); + } + if(val.hasGrail && !foundGrail[val.player]) + { + foundGrail[val.player] = true; + icons.push_back({ graphics->playerColors[val.player], val.day, imageGrail, CGI->generaltexth->translate("vcmi.statisticWindow.icon.grailFound") }); + } + } + + return icons; +} + +std::shared_ptr CStatisticScreen::getContent(Content c, EGameResID res) +{ + TData plotData; + TIcons icons = extractIcons(); + + switch (c) + { + case OVERVIEW: + return std::make_shared(contentArea.resize(-15), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), statistic); + + case CHART_RESOURCES: + plotData = extractData(statistic, [res](const StatisticDataSetEntry & val) -> float { return val.resources[res]; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])) + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", res.getNum()).get()), plotData, icons, 0); + + case CHART_INCOME: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.income; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_NUMBER_OF_HEROES: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.numberHeroes; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_NUMBER_OF_TOWNS: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.numberTowns; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_NUMBER_OF_ARTIFACTS: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.numberArtifacts; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_NUMBER_OF_DWELLINGS: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.numberDwellings; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_NUMBER_OF_MINES: + plotData = extractData(statistic, [res](StatisticDataSetEntry val) -> float { return val.numMines[res]; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])) + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", res.getNum()).get()), plotData, icons, 0); + + case CHART_ARMY_STRENGTH: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.armyStrength; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_EXPERIENCE: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.totalExperience; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 0); + + case CHART_RESOURCES_SPENT_ARMY: + plotData = extractData(statistic, [res](const StatisticDataSetEntry & val) -> float { return val.spentResourcesForArmy[res]; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])) + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", res.getNum()).get()), plotData, icons, 0); + + case CHART_RESOURCES_SPENT_BUILDINGS: + plotData = extractData(statistic, [res](const StatisticDataSetEntry & val) -> float { return val.spentResourcesForBuildings[res]; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])) + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", res.getNum()).get()), plotData, icons, 0); + + case CHART_MAP_EXPLORED: + plotData = extractData(statistic, [](const StatisticDataSetEntry & val) -> float { return val.mapExploredRatio; }); + return std::make_shared(contentArea.resize(-5), CGI->generaltexth->translate(std::get<0>(contentInfo[c])), plotData, icons, 1); + } + + return nullptr; +} + +StatisticSelector::StatisticSelector(const std::vector & texts, const std::function & cb) + : CWindowObject(BORDERED | NEEDS_ANIMATED_BACKGROUND), texts(texts), cb(cb) +{ + OBJECT_CONSTRUCTION; + pos = center(Rect(0, 0, 128 + 16, std::min(static_cast(texts.size()), LINES) * 40)); + filledBackground = std::make_shared(ImagePath::builtin("DiBoxBck"), Rect(0, 0, pos.w, pos.h)); + filledBackground->setPlayerColor(PlayerColor(1)); + + slider = std::make_shared(Point(pos.w - 16, 0), pos.h, [this](int to){ update(to); redraw(); }, LINES, texts.size(), 0, Orientation::VERTICAL, CSlider::BLUE); + slider->setPanningStep(40); + slider->setScrollBounds(Rect(-pos.w + slider->pos.w, 0, pos.w, pos.h)); + + update(0); +} + +void StatisticSelector::update(int to) +{ + OBJECT_CONSTRUCTION; + buttons.clear(); + for(int i = to; i < LINES + to; i++) + { + if(i>=texts.size()) + continue; + + auto button = std::make_shared(Point(0, 10 + (i - to) * 40), AnimationPath::builtin("GSPBUT2"), CButton::tooltip(), [this, i](bool on){ close(); cb(i); }); + button->setTextOverlay(texts[i], EFonts::FONT_SMALL, Colors::WHITE); + buttons.emplace_back(button); + } +} + +OverviewPanel::OverviewPanel(Rect position, std::string title, const StatisticDataSet & stat) + : CIntObject(), data(stat) +{ + OBJECT_CONSTRUCTION; + + pos = position + pos.topLeft(); + + layout.emplace_back(std::make_shared(pos.w / 2, 10, FONT_MEDIUM, ETextAlignment::CENTER, Colors::WHITE, title)); + + canvas = std::make_shared(Rect(0, Y_OFFS, pos.w - 16, pos.h - Y_OFFS)); + + dataExtract = { + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.playerName"), [this](PlayerColor color){ + return playerDataFilter(color).front().playerName; + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.daysSurvived"), [this](PlayerColor color){ + return CStatisticScreen::getDay(playerDataFilter(color).size()); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.maxHeroLevel"), [this](PlayerColor color){ + int maxLevel = 0; + for(const auto & val : playerDataFilter(color)) + if(maxLevel < val.maxHeroLevel) + maxLevel = val.maxHeroLevel; + return std::to_string(maxLevel); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.battleWinRatioHero"), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + if(!val.numBattlesPlayer) + return std::string(""); + float tmp = (static_cast(val.numWinBattlesPlayer) / static_cast(val.numBattlesPlayer)) * 100; + return std::to_string(static_cast(tmp)) + " %"; + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.battleWinRatioNeutral"), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + if(!val.numWinBattlesNeutral) + return std::string(""); + float tmp = (static_cast(val.numWinBattlesNeutral) / static_cast(val.numBattlesNeutral)) * 100; + return std::to_string(static_cast(tmp)) + " %"; + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.battlesHero"), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.numBattlesPlayer); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.battlesNeutral"), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.numBattlesNeutral); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.obeliskVisited"), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(static_cast(val.obeliskVisitedRatio * 100)) + " %"; + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.maxArmyStrength"), [this](PlayerColor color){ + int maxArmyStrength = 0; + for(const auto & val : playerDataFilter(color)) + if(maxArmyStrength < val.armyStrength) + maxArmyStrength = val.armyStrength; + return TextOperations::formatMetric(maxArmyStrength, 6); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::GOLD).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::GOLD]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::WOOD).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::WOOD]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::MERCURY).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::MERCURY]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::ORE).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::ORE]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::SULFUR).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::SULFUR]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::CRYSTAL).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::CRYSTAL]); + } + }, + { + CGI->generaltexth->translate("vcmi.statisticWindow.param.tradeVolume") + " - " + CGI->generaltexth->translate(TextIdentifier("core.restypes", EGameResID::GEMS).get()), [this](PlayerColor color){ + auto val = playerDataFilter(color).back(); + return std::to_string(val.tradeVolume[EGameResID::GEMS]); + } + }, + }; + + int usedLines = dataExtract.size(); + + slider = std::make_shared(Point(pos.w - 16, Y_OFFS), pos.h - Y_OFFS, [this](int to){ update(to); setRedrawParent(true); redraw(); }, LINES - 1, usedLines, 0, Orientation::VERTICAL, CSlider::BLUE); + slider->setPanningStep(canvas->pos.h / LINES); + slider->setScrollBounds(Rect(-pos.w + slider->pos.w, 0, pos.w, canvas->pos.h)); + + fieldSize = Point(canvas->pos.w / (graphics->playerColors.size() + 2), canvas->pos.h / LINES); + for(int x = 0; x < graphics->playerColors.size() + 1; x++) + for(int y = 0; y < LINES; y++) + { + int xStart = (x + (x == 0 ? 0 : 1)) * fieldSize.x; + int yStart = y * fieldSize.y; + if(x == 0 || y == 0) + canvas->addBox(Point(xStart, yStart), Point(x == 0 ? 2 * fieldSize.x : fieldSize.x, fieldSize.y), ColorRGBA(0, 0, 0, 100)); + canvas->addRectangle(Point(xStart, yStart), Point(x == 0 ? 2 * fieldSize.x : fieldSize.x, fieldSize.y), ColorRGBA(127, 127, 127, 255)); + } + + update(0); +} + +std::vector OverviewPanel::playerDataFilter(PlayerColor color) +{ + std::vector tmpData; + std::copy_if(data.data.begin(), data.data.end(), std::back_inserter(tmpData), [color](const StatisticDataSetEntry & e){ return e.player == color; }); + return tmpData; +} + +void OverviewPanel::update(int to) +{ + OBJECT_CONSTRUCTION; + + content.clear(); + for(int y = to; y < LINES - 1 + to; y++) + { + if(y >= dataExtract.size()) + continue; + + for(int x = 0; x < PlayerColor::PLAYER_LIMIT_I + 1; x++) + { + if(y == to && x < PlayerColor::PLAYER_LIMIT_I) + content.emplace_back(std::make_shared(AnimationPath::builtin("ITGFLAGS"), x, 0, 180 + x * fieldSize.x, 35)); + int xStart = (x + (x == 0 ? 0 : 1)) * fieldSize.x + (x == 0 ? fieldSize.x : (fieldSize.x / 2)); + int yStart = Y_OFFS + (y + 1 - to) * fieldSize.y + (fieldSize.y / 2); + PlayerColor tmpColor(x - 1); + if(playerDataFilter(tmpColor).size() || x == 0) + content.emplace_back(std::make_shared(xStart, yStart, FONT_TINY, ETextAlignment::CENTER, Colors::WHITE, (x == 0 ? dataExtract[y].first : dataExtract[y].second(tmpColor)), x == 0 ? (fieldSize.x * 2) : fieldSize.x)); + } + } +} + +LineChart::LineChart(Rect position, std::string title, TData data, TIcons icons, float maxY) + : CIntObject(), maxVal(0), maxDay(0) +{ + OBJECT_CONSTRUCTION; + + addUsedEvents(LCLICK | MOVE); + + pos = position + pos.topLeft(); + + layout.emplace_back(std::make_shared(pos.w / 2, 20, FONT_MEDIUM, ETextAlignment::CENTER, Colors::WHITE, title)); + + chartArea = pos.resize(-50); + chartArea.moveTo(Point(50, 50)); + + canvas = std::make_shared(Rect(0, 0, pos.w, pos.h)); + + statusBar = CGStatusBar::create(0, 0, ImagePath::builtin("radialMenu/statusBar")); + static_cast>(statusBar)->setEnabled(false); + + // additional calculations + bool skipMaxValCalc = maxY > 0; + maxVal = maxY; + for(const auto & line : data) + { + for(auto & val : line.second) + if(maxVal < val && !skipMaxValCalc) + maxVal = val; + if(maxDay < line.second.size()) + maxDay = line.second.size(); + } + + // draw + for(const auto & line : data) + { + Point lastPoint(-1, -1); + for(int i = 0; i < line.second.size(); i++) + { + float x = (static_cast(chartArea.w) / static_cast(maxDay - 1)) * static_cast(i); + float y = static_cast(chartArea.h) - (static_cast(chartArea.h) / maxVal) * line.second[i]; + Point p = Point(x, y) + chartArea.topLeft(); + + if(lastPoint.x != -1) + canvas->addLine(lastPoint, p, line.first); + + // icons + for(auto & icon : icons) + if(std::get<0>(icon) == line.first && std::get<1>(icon) == i + 1) // color && day + { + pictures.emplace_back(std::make_shared(std::get<2>(icon), Point(x - (CHART_ICON_SIZE / 2), y - (CHART_ICON_SIZE / 2)) + chartArea.topLeft())); + pictures.back()->addRClickCallback([icon](){ CRClickPopup::createAndPush(std::get<3>(icon)); }); + } + + lastPoint = p; + } + } + + // Axis + canvas->addLine(chartArea.topLeft() + Point(0, -10), chartArea.topLeft() + Point(0, chartArea.h + 10), Colors::WHITE); + canvas->addLine(chartArea.topLeft() + Point(-10, chartArea.h), chartArea.topLeft() + Point(chartArea.w + 10, chartArea.h), Colors::WHITE); + + Point p = chartArea.topLeft() + Point(-5, chartArea.h + 10); + layout.emplace_back(std::make_shared(p.x, p.y, FONT_SMALL, ETextAlignment::CENTERRIGHT, Colors::WHITE, "0")); + p = chartArea.topLeft() + Point(chartArea.w + 10, chartArea.h + 10); + layout.emplace_back(std::make_shared(p.x, p.y, FONT_SMALL, ETextAlignment::CENTER, Colors::WHITE, CStatisticScreen::getDay(maxDay))); + p = chartArea.topLeft() + Point(-5, -10); + layout.emplace_back(std::make_shared(p.x, p.y, FONT_SMALL, ETextAlignment::CENTERRIGHT, Colors::WHITE, std::to_string(static_cast(maxVal)))); + p = chartArea.bottomLeft() + Point(chartArea.w / 2, + 20); + layout.emplace_back(std::make_shared(p.x, p.y, FONT_MEDIUM, ETextAlignment::CENTER, Colors::WHITE, CGI->generaltexth->translate("core.genrltxt.64"))); +} + +void LineChart::updateStatusBar(const Point & cursorPosition) +{ + statusBar->moveTo(cursorPosition + Point(-statusBar->pos.w / 2, 20)); + statusBar->fitToRect(pos, 10); + Rect r(pos.x + chartArea.x, pos.y + chartArea.y, chartArea.w, chartArea.h); + statusBar->setEnabled(r.isInside(cursorPosition)); + if(r.isInside(cursorPosition)) + { + float x = (static_cast(maxDay) / static_cast(chartArea.w)) * (static_cast(cursorPosition.x) - static_cast(r.x)) + 1.0f; + float y = maxVal - (maxVal / static_cast(chartArea.h)) * (static_cast(cursorPosition.y) - static_cast(r.y)); + statusBar->write(CGI->generaltexth->translate("core.genrltxt.64") + ": " + CStatisticScreen::getDay(x) + " " + CGI->generaltexth->translate("vcmi.statisticWindow.value") + ": " + (static_cast(y) > 0 ? std::to_string(static_cast(y)) : std::to_string(y))); + } + setRedrawParent(true); + redraw(); +} + +void LineChart::mouseMoved(const Point & cursorPosition, const Point & lastUpdateDistance) +{ + updateStatusBar(cursorPosition); +} + +void LineChart::clickPressed(const Point & cursorPosition) +{ + updateStatusBar(cursorPosition); +} diff --git a/client/mainmenu/CStatisticScreen.h b/client/mainmenu/CStatisticScreen.h new file mode 100644 index 000000000..d2ec9c667 --- /dev/null +++ b/client/mainmenu/CStatisticScreen.h @@ -0,0 +1,134 @@ +/* + * CStatisticScreen.h, part of VCMI engine + * + * Authors: listed in file AUTHORS in main folder + * + * License: GNU General Public License v2.0 or later + * Full text of license available in license.txt file, in main folder + * + */ +#pragma once +#include "../windows/CWindowObject.h" +#include "../../lib/gameState/GameStatistics.h" + +class FilledTexturePlayerColored; +class CToggleButton; +class GraphicalPrimitiveCanvas; +class LineChart; +class CGStatusBar; +class ComboBox; +class CSlider; +class IImage; +class CPicture; + +using TData = std::vector>>; +using TIcons = std::vector, std::string>>; // Color, Day, Image, Helptext + +const int CHART_ICON_SIZE = 32; + +class CStatisticScreen : public CWindowObject +{ + enum Content { + OVERVIEW, + CHART_RESOURCES, + CHART_INCOME, + CHART_NUMBER_OF_HEROES, + CHART_NUMBER_OF_TOWNS, + CHART_NUMBER_OF_ARTIFACTS, + CHART_NUMBER_OF_DWELLINGS, + CHART_NUMBER_OF_MINES, + CHART_ARMY_STRENGTH, + CHART_EXPERIENCE, + CHART_RESOURCES_SPENT_ARMY, + CHART_RESOURCES_SPENT_BUILDINGS, + CHART_MAP_EXPLORED, + }; + std::map> contentInfo = { // tuple: textid, resource selection needed + { OVERVIEW, { "vcmi.statisticWindow.title.overview", false } }, + { CHART_RESOURCES, { "vcmi.statisticWindow.title.resources", true } }, + { CHART_INCOME, { "vcmi.statisticWindow.title.income", false } }, + { CHART_NUMBER_OF_HEROES, { "vcmi.statisticWindow.title.numberOfHeroes", false } }, + { CHART_NUMBER_OF_TOWNS, { "vcmi.statisticWindow.title.numberOfTowns", false } }, + { CHART_NUMBER_OF_ARTIFACTS, { "vcmi.statisticWindow.title.numberOfArtifacts", false } }, + { CHART_NUMBER_OF_DWELLINGS, { "vcmi.statisticWindow.title.numberOfDwellings", false } }, + { CHART_NUMBER_OF_MINES, { "vcmi.statisticWindow.title.numberOfMines", true } }, + { CHART_ARMY_STRENGTH, { "vcmi.statisticWindow.title.armyStrength", false } }, + { CHART_EXPERIENCE, { "vcmi.statisticWindow.title.experience", false } }, + { CHART_RESOURCES_SPENT_ARMY, { "vcmi.statisticWindow.title.resourcesSpentArmy", true } }, + { CHART_RESOURCES_SPENT_BUILDINGS, { "vcmi.statisticWindow.title.resourcesSpentBuildings", true } }, + { CHART_MAP_EXPLORED, { "vcmi.statisticWindow.title.mapExplored", false } }, + }; + + std::shared_ptr filledBackground; + std::vector> layout; + std::shared_ptr buttonCsvSave; + std::shared_ptr buttonSelect; + StatisticDataSet statistic; + std::shared_ptr mainContent; + Rect contentArea; + + using ExtractFunctor = std::function; + TData extractData(const StatisticDataSet & stat, const ExtractFunctor & selector) const; + TIcons extractIcons() const; + std::shared_ptr getContent(Content c, EGameResID res); + void onSelectButton(); +public: + CStatisticScreen(const StatisticDataSet & stat); + static std::string getDay(int day); +}; + +class StatisticSelector : public CWindowObject +{ + std::shared_ptr filledBackground; + std::vector> buttons; + std::shared_ptr slider; + + const int LINES = 10; + + std::vector texts; + std::function cb; + + void update(int to); +public: + StatisticSelector(const std::vector & texts, const std::function & cb); +}; + +class OverviewPanel : public CIntObject +{ + std::shared_ptr canvas; + std::vector> layout; + std::vector> content; + std::shared_ptr slider; + + Point fieldSize; + StatisticDataSet data; + + std::vector>> dataExtract; + + const int LINES = 15; + const int Y_OFFS = 30; + + std::vector playerDataFilter(PlayerColor color); + void update(int to); +public: + OverviewPanel(Rect position, std::string title, const StatisticDataSet & stat); +}; + +class LineChart : public CIntObject +{ + std::shared_ptr canvas; + std::vector> layout; + std::shared_ptr statusBar; + std::vector> pictures; + + Rect chartArea; + float maxVal; + int maxDay; + + void updateStatusBar(const Point & cursorPosition); +public: + LineChart(Rect position, std::string title, TData data, TIcons icons, float maxY); + + void mouseMoved(const Point & cursorPosition, const Point & lastUpdateDistance) override; + void clickPressed(const Point & cursorPosition) override; +}; diff --git a/client/media/CVideoHandler.cpp b/client/media/CVideoHandler.cpp index d9ed30168..8a648646f 100644 --- a/client/media/CVideoHandler.cpp +++ b/client/media/CVideoHandler.cpp @@ -339,10 +339,11 @@ FFMpegStream::~FFMpegStream() { av_frame_free(&frame); +#if (LIBAVCODEC_VERSION_MAJOR < 61 ) + // deprecated, apparently no longer necessary - avcodec_free_context should suffice avcodec_close(codecContext); - avcodec_free_context(&codecContext); +#endif - avcodec_close(codecContext); avcodec_free_context(&codecContext); avformat_close_input(&formatContext); diff --git a/client/widgets/Images.cpp b/client/widgets/Images.cpp index 660a1ecee..1b968c04e 100644 --- a/client/widgets/Images.cpp +++ b/client/widgets/Images.cpp @@ -39,6 +39,8 @@ CPicture::CPicture(std::shared_ptr image, const Point & position) pos += position; pos.w = bg->width(); pos.h = bg->height(); + + addUsedEvents(SHOW_POPUP); } CPicture::CPicture( const ImagePath &bmpname, int x, int y ) @@ -66,6 +68,8 @@ CPicture::CPicture( const ImagePath & bmpname, const Point & position ) { pos.w = pos.h = 0; } + + addUsedEvents(SHOW_POPUP); } CPicture::CPicture(const ImagePath & bmpname, const Rect &SrcRect, int x, int y) @@ -74,6 +78,8 @@ CPicture::CPicture(const ImagePath & bmpname, const Rect &SrcRect, int x, int y) srcRect = SrcRect; pos.w = srcRect->w; pos.h = srcRect->h; + + addUsedEvents(SHOW_POPUP); } CPicture::CPicture(std::shared_ptr image, const Rect &SrcRect, int x, int y) @@ -82,6 +88,8 @@ CPicture::CPicture(std::shared_ptr image, const Rect &SrcRect, int x, in srcRect = SrcRect; pos.w = srcRect->w; pos.h = srcRect->h; + + addUsedEvents(SHOW_POPUP); } void CPicture::show(Canvas & to) @@ -119,6 +127,17 @@ void CPicture::setPlayerColor(PlayerColor player) bg->playerColored(player); } +void CPicture::addRClickCallback(const std::function & callback) +{ + rCallback = callback; +} + +void CPicture::showPopupWindow(const Point & cursorPosition) +{ + if(rCallback) + rCallback(); +} + CFilledTexture::CFilledTexture(const ImagePath & imageName, Rect position) : CIntObject(0, position.topLeft()) , texture(GH.renderHandler().loadImage(imageName, EImageBlitMode::COLORKEY)) diff --git a/client/widgets/Images.h b/client/widgets/Images.h index a70d12da4..c650b9831 100644 --- a/client/widgets/Images.h +++ b/client/widgets/Images.h @@ -26,6 +26,7 @@ class IImage; class CPicture : public CIntObject { std::shared_ptr bg; + std::function rCallback; public: /// if set, only specified section of internal image will be rendered @@ -57,8 +58,11 @@ public: void scaleTo(Point size); void setPlayerColor(PlayerColor player); + void addRClickCallback(const std::function & callback); + void show(Canvas & to) override; void showAll(Canvas & to) override; + void showPopupWindow(const Point & cursorPosition) override; }; /// area filled with specific texture diff --git a/client/widgets/TextControls.cpp b/client/widgets/TextControls.cpp index 191ede0f5..c2d1f8cb0 100644 --- a/client/widgets/TextControls.cpp +++ b/client/widgets/TextControls.cpp @@ -330,6 +330,7 @@ Rect CMultiLineLabel::getTextLocation() case ETextAlignment::TOPLEFT: return Rect(pos.topLeft(), textSize); case ETextAlignment::TOPCENTER: return Rect(pos.topLeft(), textSize); case ETextAlignment::CENTER: return Rect(pos.topLeft() + textOffset / 2, textSize); + case ETextAlignment::CENTERRIGHT: return Rect(pos.topLeft() + Point(textOffset.x, textOffset.y / 2), textSize); case ETextAlignment::BOTTOMRIGHT: return Rect(pos.topLeft() + textOffset, textSize); } assert(0); @@ -543,7 +544,6 @@ void CGStatusBar::activate() void CGStatusBar::deactivate() { - assert(GH.statusbar().get() == this); GH.setStatusbar(nullptr); if (enteringText) diff --git a/conanfile.py b/conanfile.py index 220340bd0..fa140a728 100644 --- a/conanfile.py +++ b/conanfile.py @@ -20,6 +20,7 @@ class VCMI(ConanFile): "sdl_mixer/[~2.0.4]", "sdl_ttf/[~2.0.18]", "onetbb/[^2021.3]", + "xz_utils/[>=5.2.5]", # Required for innoextract ] requires = _libRequires + _clientRequires @@ -87,24 +88,64 @@ class VCMI(ConanFile): self.options["boost"].without_type_erasure = True self.options["boost"].without_wave = True - self.options["ffmpeg"].avdevice = False - self.options["ffmpeg"].avfilter = False - self.options["ffmpeg"].postproc = False - self.options["ffmpeg"].swresample = False - self.options["ffmpeg"].with_asm = self.settings.os != "Android" + self.options["ffmpeg"].disable_all_bitstream_filters = True + self.options["ffmpeg"].disable_all_decoders = True + self.options["ffmpeg"].disable_all_demuxers = True + self.options["ffmpeg"].disable_all_encoders = True + self.options["ffmpeg"].disable_all_filters = True + self.options["ffmpeg"].disable_all_hardware_accelerators = True + self.options["ffmpeg"].disable_all_muxers = True + self.options["ffmpeg"].disable_all_parsers = True + self.options["ffmpeg"].disable_all_protocols = True + + self.options["ffmpeg"].with_asm = False + self.options["ffmpeg"].with_bzip2 = False self.options["ffmpeg"].with_freetype = False - self.options["ffmpeg"].with_libfdk_aac = False + self.options["ffmpeg"].with_libaom = False + self.options["ffmpeg"].with_libdav1d = False + self.options["ffmpeg"].with_libiconv = False self.options["ffmpeg"].with_libmp3lame = False + self.options["ffmpeg"].with_libsvtav1 = False self.options["ffmpeg"].with_libvpx = False self.options["ffmpeg"].with_libwebp = False self.options["ffmpeg"].with_libx264 = False self.options["ffmpeg"].with_libx265 = False + self.options["ffmpeg"].with_lzma = True self.options["ffmpeg"].with_openh264 = False self.options["ffmpeg"].with_openjpeg = False self.options["ffmpeg"].with_opus = False self.options["ffmpeg"].with_programs = False + self.options["ffmpeg"].with_sdl = False self.options["ffmpeg"].with_ssl = False self.options["ffmpeg"].with_vorbis = False + self.options["ffmpeg"].with_zlib = False + if self.settings.os != "Android": + self.options["ffmpeg"].with_libfdk_aac = False + + self.options["ffmpeg"].avcodec = True + self.options["ffmpeg"].avdevice = False + self.options["ffmpeg"].avfilter = False + self.options["ffmpeg"].avformat = True + self.options["ffmpeg"].postproc = False + self.options["ffmpeg"].swresample = True # For resampling of audio in 'planar' formats + self.options["ffmpeg"].swscale = True # For video scaling + + # We want following options supported: + # H3:SoD - .bik and .smk + # H3:HD - ogg container / theora video / vorbis sound (not supported by vcmi at the moment, but might be supported in future) + # and for mods - webm container / vp8 or vp9 video / opus sound + # TODO: add av1 support for mods (requires enabling libdav1d which currently fails to build via Conan) + self.options["ffmpeg"].enable_protocols = "file" + self.options["ffmpeg"].enable_demuxers = "bink,binka,ogg,smacker,webm_dash_manifest" + self.options["ffmpeg"].enable_parsers = "opus,vorbis,vp8,vp9,webp" + self.options["ffmpeg"].enable_decoders = "bink,binkaudio_dct,binkaudio_rdft,smackaud,smacker,theora,vorbis,vp8,vp9,opus" + + #optionally, for testing - enable ffplay/ffprobe binaries in conan package: + #if self.settings.os == "Windows": + # self.options["ffmpeg"].with_programs = True + # self.options["ffmpeg"].avfilter = True + # self.options["ffmpeg"].with_sdl = True + # self.options["ffmpeg"].enable_filters = "aresample,scale" self.options["sdl"].sdl2main = self.settings.os != "iOS" self.options["sdl"].vulkan = False @@ -198,7 +239,7 @@ class VCMI(ConanFile): # client if self.options.with_ffmpeg: - self.requires("ffmpeg/[^4.4]") + self.requires("ffmpeg/[>=4.4]") # launcher if self.settings.os == "Android": diff --git a/config/shortcutsConfig.json b/config/shortcutsConfig.json index 0193ebeb4..41ee66f22 100644 --- a/config/shortcutsConfig.json +++ b/config/shortcutsConfig.json @@ -137,6 +137,7 @@ "heroToggleTactics": "B", "highScoresCampaigns": "C", "highScoresReset": "R", + "highScoresStatistics": ".", "highScoresScenarios": "S", "kingdomHeroesTab": "H", "kingdomTownsTab": "T", diff --git a/docs/developers/Conan.md b/docs/developers/Conan.md index af99c5495..8c754ce46 100644 --- a/docs/developers/Conan.md +++ b/docs/developers/Conan.md @@ -27,12 +27,12 @@ The following platforms are supported and known to work, others might require ch - **Windows**: libraries are built with x86_64-mingw-w64-gcc version 10 (which is available in repositories of Ubuntu 22.04) - **Android**: libraries are built with NDK r25c (25.2.9519653) -2. Download the binaries archive and unpack it to `~/.conan` directory: +2. Download the binaries archive and unpack it to `~/.conan` directory from https://github.com/vcmi/vcmi-dependencies/releases/latest - - [macOS](https://github.com/vcmi/vcmi-deps-macos/releases/latest): pick **intel.txz** if you have Intel Mac, otherwise - **intel-cross-arm.txz** - - [iOS](https://github.com/vcmi/vcmi-ios-deps/releases/latest) - - [Windows](https://github.com/vcmi/vcmi-deps-windows-conan/releases/latest): pick **vcmi-deps-windows-conan-w64.tgz** if you want x86_64, otherwise pick **vcmi-deps-windows-conan-w32.tgz** - - [Android](https://github.com/vcmi/vcmi-dependencies/releases): current limitation is that building works only on a macOS host due to Qt 5 for Android being compiled on macOS. Simply delete directory `~/.conan/data/qt/5.15.x/_/_/package` (`5.15.x` is a placeholder) after unpacking the archive and build Qt from source. Alternatively, if you have (or are [willing to build](https://github.com/vcmi/vcmi-ios-deps#note-for-arm-macs)) Qt host tools for your platform, then simply replace those in the archive with yours and most probably it would work. + - macOS: pick **dependencies-mac-intel.txz** if you have Intel Mac, otherwise - **dependencies-mac-arm.txz** + - iOS: pick ***dependencies-ios.txz*** + - Windows: currently only mingw is supported. Pick **dependencies-mingw.tgz** if you want x86_64, otherwise pick **dependencies-mingw-32.tgz** + - Android: current limitation is that building works only on a macOS host due to Qt 5 for Android being compiled on macOS. Simply delete directory `~/.conan/data/qt/5.15.x/_/_/package` (`5.15.x` is a placeholder) after unpacking the archive and build Qt from source. Alternatively, if you have (or are [willing to build](https://github.com/vcmi/vcmi-ios-deps#note-for-arm-macs)) Qt host tools for your platform, then simply replace those in the archive with yours and most probably it would work. 3. Only if you have Apple Silicon Mac and trying to build for macOS or iOS: diff --git a/docs/modders/File_Formats.md b/docs/modders/File_Formats.md new file mode 100644 index 000000000..d1466e4cf --- /dev/null +++ b/docs/modders/File_Formats.md @@ -0,0 +1,80 @@ +# File Formats + +This page describes which file formats are supported by vcmi. + +In most cases, VCMI supports formats that were supported by Heroes III, with addition of new formats that are more convenient to use without specialized tools. See categories below for more details on specific formats + +### Images + +For images VCMI supports: +- png. Recommended for usage in mods +- bmp. While this format is supported, bmp images have no compressions leading to large file sizes +- pcx (h3 version). Note that this is format that is specific to Heroes III and has nothing in common with widely known .pcx format. Files in this format generally can only be found inside of .lod archive of Heroes III and are usually extracted as .bmp files + +Transparency support: +VCMI supports transparency (alpha) channel, both in png and in bmp images. There may be cases where transparency is not fully supported. If you discover such cases, please report them. + +For performance reasons, please use alpha channel only in places where transparency is actually required and remove alpha channel from image othervice + +Palette support: +TODO: describe how palettes work in vcmi + +### Animations + +For animations VCMI supports .def format from Heroes III as well as alternative json-based. See [Animation Format](Animation_Format.md) for more details + +### Sounds + +For sounds VCMI currently requires .wav format. Generally, VCMI will support any .wav parameters, however you might want to use high-bitrate versions, such as 44100 Hz or 48000 Hz, 32 bit, 1 or 2 channels + +Support for additional formats, such as ogg/vorbis and ogg/opus is likely to be added in future + +### Music + +For sounds VCMI currently requires .mp3 format. Support for additional formats, such as ogg/vorbis and ogg/opus is likely to be added in future + +### Video + +Starting from VCMI 1.6, following video container formats are supported by VCMI: + +- .bik - one of the formats used by Heroes III +- .smk - one of the formats used by Heroes III. Note that these videos generally have lower quality and are only used as fallback if no other formats are found +- .ogv - format used by Heroes III: HD Edition +- .webm - modern, free format that is recommended for modding. + +Supported video codecs: +- bink and smacker - formats used by Heroes III, should be used only to avoid re-encoding +- theora - used by Heroes III: HD Edition +- vp8 - modern format with way better compression compared to formats used by Heroes III +- vp9 - recommended, this format is improvement of vp9 format and should be used as a default option + +Support for av1 video codec is likely to be added in future. + +Supported audio codecs: +- binkaudio and smackaud - formats used by Heroes III +- vorbis - modern format with good compression level +- opus - recommended, improvement over vorbis. Any bitrate is supported, with 128 kbit probably being the best option + +### Json + +For most of configuration files, VCMI uses [JSON format](http://en.wikipedia.org/wiki/Json) with some extensions from [JSON5](https://spec.json5.org/) format, such as comments. + +### Maps + +TODO: describe + +### Campaigns + +TODO: describe + +### Map Templates + +TODO: describe + +### Archives + +TODO: describe + +### Txt + +TODO: describe diff --git a/docs/modders/Readme.md b/docs/modders/Readme.md index 5c788b539..1bf8c80ce 100644 --- a/docs/modders/Readme.md +++ b/docs/modders/Readme.md @@ -19,8 +19,10 @@ Example of how directory structure of your mod may look like: music/ - music files. Mp3 and ogg/vorbis are supported sounds/ - sound files, in wav format. sprites/ - animation, image sets (H3 .def files or VCMI .json files) - video/ - video files, .bik or .smk + video/ - video files, .bik, .smk, .ogv .webm ``` +See [File Formats](File_Formats.md) page for more information on which formats are supported or recommended for vcmi + ## Creating mod file diff --git a/lib/CGameInfoCallback.h b/lib/CGameInfoCallback.h index 6b9f251a5..5cca4276d 100644 --- a/lib/CGameInfoCallback.h +++ b/lib/CGameInfoCallback.h @@ -56,7 +56,7 @@ public: // //various virtual int getDate(Date mode=Date::DAY) const = 0; //mode=0 - total days in game, mode=1 - day of week, mode=2 - current week, mode=3 - current month -// const StartInfo * getStartInfo(bool beforeRandomization = false)const; + virtual const StartInfo * getStartInfo(bool beforeRandomization = false) const = 0; virtual bool isAllowed(SpellID id) const = 0; virtual bool isAllowed(ArtifactID id) const = 0; virtual bool isAllowed(SecondarySkill id) const = 0; @@ -143,7 +143,7 @@ protected: public: //various int getDate(Date mode=Date::DAY)const override; //mode=0 - total days in game, mode=1 - day of week, mode=2 - current week, mode=3 - current month - virtual const StartInfo * getStartInfo(bool beforeRandomization = false)const; + const StartInfo * getStartInfo(bool beforeRandomization = false) const override; bool isAllowed(SpellID id) const override; bool isAllowed(ArtifactID id) const override; bool isAllowed(SecondarySkill id) const override; diff --git a/lib/Color.h b/lib/Color.h index 6ad89513a..1bc03414a 100644 --- a/lib/Color.h +++ b/lib/Color.h @@ -57,6 +57,11 @@ public: h & b; h & a; } + + bool operator==(ColorRGBA const& rhs) const + { + return r == rhs.r && g == rhs.g && b == rhs.b && a == rhs.a; + } }; VCMI_LIB_NAMESPACE_END diff --git a/lib/battle/AccessibilityInfo.cpp b/lib/battle/AccessibilityInfo.cpp index 44e093f99..22fa5196e 100644 --- a/lib/battle/AccessibilityInfo.cpp +++ b/lib/battle/AccessibilityInfo.cpp @@ -18,9 +18,19 @@ VCMI_LIB_NAMESPACE_BEGIN bool AccessibilityInfo::tileAccessibleWithGate(BattleHex tile, BattleSide side) const { //at(otherHex) != EAccessibility::ACCESSIBLE && (at(otherHex) != EAccessibility::GATE || side != BattleSide::DEFENDER) - if(at(tile) != EAccessibility::ACCESSIBLE) - if(at(tile) != EAccessibility::GATE || side != BattleSide::DEFENDER) + auto accessibility = at(tile); + + if(accessibility == EAccessibility::ALIVE_STACK) + { + auto destructible = destructibleEnemyTurns.find(tile); + + return destructible != destructibleEnemyTurns.end(); + } + + if(accessibility != EAccessibility::ACCESSIBLE) + if(accessibility != EAccessibility::GATE || side != BattleSide::DEFENDER) return false; + return true; } diff --git a/lib/battle/AccessibilityInfo.h b/lib/battle/AccessibilityInfo.h index f9604a490..1352c4da2 100644 --- a/lib/battle/AccessibilityInfo.h +++ b/lib/battle/AccessibilityInfo.h @@ -35,6 +35,8 @@ using TAccessibilityArray = std::array destructibleEnemyTurns; + public: bool accessible(BattleHex tile, const battle::Unit * stack) const; //checks for both tiles if stack is double wide bool accessible(BattleHex tile, bool doubleWide, BattleSide side) const; //checks for both tiles if stack is double wide diff --git a/lib/battle/CBattleInfoCallback.cpp b/lib/battle/CBattleInfoCallback.cpp index 53bec15b1..37900ebf9 100644 --- a/lib/battle/CBattleInfoCallback.cpp +++ b/lib/battle/CBattleInfoCallback.cpp @@ -1052,16 +1052,29 @@ ReachabilityInfo CBattleInfoCallback::makeBFS(const AccessibilityInfo &accessibi continue; const int costToNeighbour = ret.distances[curHex.hex] + 1; + for(BattleHex neighbour : BattleHex::neighbouringTilesCache[curHex.hex]) { if(neighbour.isValid()) { + auto additionalCost = 0; + + if(params.bypassEnemyStacks) + { + auto enemyToBypass = params.destructibleEnemyTurns.find(neighbour); + + if(enemyToBypass != params.destructibleEnemyTurns.end()) + { + additionalCost = enemyToBypass->second; + } + } + const int costFoundSoFar = ret.distances[neighbour.hex]; - if(accessibleCache[neighbour.hex] && costToNeighbour < costFoundSoFar) + if(accessibleCache[neighbour.hex] && costToNeighbour + additionalCost < costFoundSoFar) { hexq.push(neighbour); - ret.distances[neighbour.hex] = costToNeighbour; + ret.distances[neighbour.hex] = costToNeighbour + additionalCost; ret.predecessors[neighbour.hex] = curHex; } } @@ -1236,7 +1249,13 @@ ReachabilityInfo CBattleInfoCallback::getReachability(const ReachabilityInfo::Pa if(params.flying) return getFlyingReachability(params); else - return makeBFS(getAccessibility(params.knownAccessible), params); + { + auto accessibility = getAccessibility(params.knownAccessible); + + accessibility.destructibleEnemyTurns = params.destructibleEnemyTurns; + + return makeBFS(accessibility, params); + } } ReachabilityInfo CBattleInfoCallback::getFlyingReachability(const ReachabilityInfo::Parameters ¶ms) const diff --git a/lib/battle/ReachabilityInfo.h b/lib/battle/ReachabilityInfo.h index de4fe21c4..f0c5ed948 100644 --- a/lib/battle/ReachabilityInfo.h +++ b/lib/battle/ReachabilityInfo.h @@ -29,7 +29,9 @@ struct DLL_LINKAGE ReachabilityInfo bool doubleWide = false; bool flying = false; bool ignoreKnownAccessible = false; //Ignore obstacles if it is in accessible hexes + bool bypassEnemyStacks = false; // in case of true will count amount of turns needed to kill enemy and thus move forward std::vector knownAccessible; //hexes that will be treated as accessible, even if they're occupied by stack (by default - tiles occupied by stack we do reachability for, so it doesn't block itself) + std::map destructibleEnemyTurns; // hom many turns it is needed to kill enemy on specific hex BattleHex startPosition; //assumed position of stack BattleSide perspective = BattleSide::ALL_KNOWING; //some obstacles (eg. quicksands) may be invisible for some side diff --git a/lib/filesystem/ResourcePath.cpp b/lib/filesystem/ResourcePath.cpp index 4a94e34de..347c3e9fd 100644 --- a/lib/filesystem/ResourcePath.cpp +++ b/lib/filesystem/ResourcePath.cpp @@ -115,6 +115,7 @@ EResType EResTypeHelper::getTypeFromExtension(std::string extension) {".FLAC", EResType::SOUND}, {".SMK", EResType::VIDEO_LOW_QUALITY}, {".BIK", EResType::VIDEO}, + {".OGV", EResType::VIDEO}, {".WEBM", EResType::VIDEO}, {".ZIP", EResType::ARCHIVE_ZIP}, {".LOD", EResType::ARCHIVE_LOD}, diff --git a/lib/filesystem/ResourcePath.h b/lib/filesystem/ResourcePath.h index fde6bec9f..4f4b4e9a1 100644 --- a/lib/filesystem/ResourcePath.h +++ b/lib/filesystem/ResourcePath.h @@ -28,7 +28,7 @@ class JsonSerializeFormat; * Font: .fnt * Image: .bmp, .jpg, .pcx, .png, .tga * Sound: .wav .82m - * Video: .smk, .bik .mjpg .mpg .webm + * Video: .smk, .bik .ogv .webm * Music: .mp3, .ogg * Archive: .lod, .snd, .vid .pac .zip * Palette: .pal diff --git a/lib/gameState/CGameState.cpp b/lib/gameState/CGameState.cpp index 7598801b6..ce75c0cf2 100644 --- a/lib/gameState/CGameState.cpp +++ b/lib/gameState/CGameState.cpp @@ -129,26 +129,26 @@ HeroTypeID CGameState::pickUnusedHeroTypeRandomly(const PlayerColor & owner) throw std::runtime_error("Can not allocate hero. All heroes are already used."); } -int CGameState::getDate(Date mode) const +int CGameState::getDate(int d, Date mode) { int temp; switch (mode) { case Date::DAY: - return day; + return d; case Date::DAY_OF_WEEK: //day of week - temp = (day)%7; // 1 - Monday, 7 - Sunday + temp = (d)%7; // 1 - Monday, 7 - Sunday return temp ? temp : 7; case Date::WEEK: //current week - temp = ((day-1)/7)+1; + temp = ((d-1)/7)+1; if (!(temp%4)) return 4; else return (temp%4); case Date::MONTH: //current month - return ((day-1)/28)+1; + return ((d-1)/28)+1; case Date::DAY_OF_MONTH: //day of month - temp = (day)%28; + temp = (d)%28; if (temp) return temp; else return 28; @@ -156,6 +156,11 @@ int CGameState::getDate(Date mode) const return 0; } +int CGameState::getDate(Date mode) const +{ + return getDate(day, mode); +} + CGameState::CGameState() { gs = this; diff --git a/lib/gameState/CGameState.h b/lib/gameState/CGameState.h index c63f5cc52..06de90720 100644 --- a/lib/gameState/CGameState.h +++ b/lib/gameState/CGameState.h @@ -138,6 +138,7 @@ public: bool isVisible(int3 pos, const std::optional & player) const override; bool isVisible(const CGObjectInstance * obj, const std::optional & player) const override; + static int getDate(int day, Date mode); int getDate(Date mode=Date::DAY) const override; //mode=0 - total days in game, mode=1 - day of week, mode=2 - current week, mode=3 - current month // ----- getters, setters ----- diff --git a/lib/gameState/GameStatistics.cpp b/lib/gameState/GameStatistics.cpp index e3d78f851..8551e18de 100644 --- a/lib/gameState/GameStatistics.cpp +++ b/lib/gameState/GameStatistics.cpp @@ -11,6 +11,7 @@ #include "GameStatistics.h" #include "../CPlayerState.h" #include "../constants/StringConstants.h" +#include "../VCMIDirs.h" #include "CGameState.h" #include "TerrainHandler.h" #include "CHeroHandler.h" @@ -44,6 +45,7 @@ StatisticDataSetEntry StatisticDataSet::createEntry(const PlayerState * ps, cons data.timestamp = std::time(nullptr); data.day = gs->getDate(Date::DAY); data.player = ps->color; + data.playerName = gs->getStartInfo()->playerInfos.at(ps->color).name; data.team = ps->team; data.isHuman = ps->isHuman(); data.status = ps->status; @@ -71,101 +73,122 @@ StatisticDataSetEntry StatisticDataSet::createEntry(const PlayerState * ps, cons data.spentResourcesForArmy = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).spentResourcesForArmy : TResources(); data.spentResourcesForBuildings = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).spentResourcesForBuildings : TResources(); data.tradeVolume = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).tradeVolume : TResources(); + data.eventCapturedTown = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).lastCapturedTownDay == gs->getDate(Date::DAY) : false; + data.eventDefeatedStrongestHero = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).lastDefeatedStrongestHeroDay == gs->getDate(Date::DAY) : false; data.movementPointsUsed = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).movementPointsUsed : 0; return data; } -std::string StatisticDataSet::toCsv() +std::string StatisticDataSet::toCsv(std::string sep) { std::stringstream ss; auto resources = std::vector{EGameResID::GOLD, EGameResID::WOOD, EGameResID::MERCURY, EGameResID::ORE, EGameResID::SULFUR, EGameResID::CRYSTAL, EGameResID::GEMS}; - ss << "Map" << ";"; - ss << "Timestamp" << ";"; - ss << "Day" << ";"; - ss << "Player" << ";"; - ss << "Team" << ";"; - ss << "IsHuman" << ";"; - ss << "Status" << ";"; - ss << "NumberHeroes" << ";"; - ss << "NumberTowns" << ";"; - ss << "NumberArtifacts" << ";"; - ss << "NumberDwellings" << ";"; - ss << "ArmyStrength" << ";"; - ss << "TotalExperience" << ";"; - ss << "Income" << ";"; - ss << "MapExploredRatio" << ";"; - ss << "ObeliskVisitedRatio" << ";"; - ss << "TownBuiltRatio" << ";"; - ss << "HasGrail" << ";"; - ss << "Score" << ";"; - ss << "MaxHeroLevel" << ";"; - ss << "NumBattlesNeutral" << ";"; - ss << "NumBattlesPlayer" << ";"; - ss << "NumWinBattlesNeutral" << ";"; - ss << "NumWinBattlesPlayer" << ";"; - ss << "NumHeroSurrendered" << ";"; - ss << "NumHeroEscaped" << ";"; + ss << "Map" << sep; + ss << "Timestamp" << sep; + ss << "Day" << sep; + ss << "Player" << sep; + ss << "PlayerName" << sep; + ss << "Team" << sep; + ss << "IsHuman" << sep; + ss << "Status" << sep; + ss << "NumberHeroes" << sep; + ss << "NumberTowns" << sep; + ss << "NumberArtifacts" << sep; + ss << "NumberDwellings" << sep; + ss << "ArmyStrength" << sep; + ss << "TotalExperience" << sep; + ss << "Income" << sep; + ss << "MapExploredRatio" << sep; + ss << "ObeliskVisitedRatio" << sep; + ss << "TownBuiltRatio" << sep; + ss << "HasGrail" << sep; + ss << "Score" << sep; + ss << "MaxHeroLevel" << sep; + ss << "NumBattlesNeutral" << sep; + ss << "NumBattlesPlayer" << sep; + ss << "NumWinBattlesNeutral" << sep; + ss << "NumWinBattlesPlayer" << sep; + ss << "NumHeroSurrendered" << sep; + ss << "NumHeroEscaped" << sep; + ss << "EventCapturedTown" << sep; + ss << "EventDefeatedStrongestHero" << sep; ss << "MovementPointsUsed"; for(auto & resource : resources) - ss << ";" << GameConstants::RESOURCE_NAMES[resource]; + ss << sep << GameConstants::RESOURCE_NAMES[resource]; for(auto & resource : resources) - ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "Mines"; + ss << sep << GameConstants::RESOURCE_NAMES[resource] + "Mines"; for(auto & resource : resources) - ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForArmy"; + ss << sep << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForArmy"; for(auto & resource : resources) - ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForBuildings"; + ss << sep << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForBuildings"; for(auto & resource : resources) - ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "TradeVolume"; + ss << sep << GameConstants::RESOURCE_NAMES[resource] + "TradeVolume"; ss << "\r\n"; for(auto & entry : data) { - ss << entry.map << ";"; - ss << vstd::getFormattedDateTime(entry.timestamp, "%Y-%m-%dT%H:%M:%S") << ";"; - ss << entry.day << ";"; - ss << GameConstants::PLAYER_COLOR_NAMES[entry.player] << ";"; - ss << entry.team.getNum() << ";"; - ss << entry.isHuman << ";"; - ss << static_cast(entry.status) << ";"; - ss << entry.numberHeroes << ";"; - ss << entry.numberTowns << ";"; - ss << entry.numberArtifacts << ";"; - ss << entry.numberDwellings << ";"; - ss << entry.armyStrength << ";"; - ss << entry.totalExperience << ";"; - ss << entry.income << ";"; - ss << entry.mapExploredRatio << ";"; - ss << entry.obeliskVisitedRatio << ";"; - ss << entry.townBuiltRatio << ";"; - ss << entry.hasGrail << ";"; - ss << entry.score << ";"; - ss << entry.maxHeroLevel << ";"; - ss << entry.numBattlesNeutral << ";"; - ss << entry.numBattlesPlayer << ";"; - ss << entry.numWinBattlesNeutral << ";"; - ss << entry.numWinBattlesPlayer << ";"; - ss << entry.numHeroSurrendered << ";"; - ss << entry.numHeroEscaped << ";"; + ss << entry.map << sep; + ss << vstd::getFormattedDateTime(entry.timestamp, "%Y-%m-%dT%H:%M:%S") << sep; + ss << entry.day << sep; + ss << GameConstants::PLAYER_COLOR_NAMES[entry.player] << sep; + ss << entry.playerName << sep; + ss << entry.team.getNum() << sep; + ss << entry.isHuman << sep; + ss << static_cast(entry.status) << sep; + ss << entry.numberHeroes << sep; + ss << entry.numberTowns << sep; + ss << entry.numberArtifacts << sep; + ss << entry.numberDwellings << sep; + ss << entry.armyStrength << sep; + ss << entry.totalExperience << sep; + ss << entry.income << sep; + ss << entry.mapExploredRatio << sep; + ss << entry.obeliskVisitedRatio << sep; + ss << entry.townBuiltRatio << sep; + ss << entry.hasGrail << sep; + ss << entry.score << sep; + ss << entry.maxHeroLevel << sep; + ss << entry.numBattlesNeutral << sep; + ss << entry.numBattlesPlayer << sep; + ss << entry.numWinBattlesNeutral << sep; + ss << entry.numWinBattlesPlayer << sep; + ss << entry.numHeroSurrendered << sep; + ss << entry.numHeroEscaped << sep; + ss << entry.eventCapturedTown << sep; + ss << entry.eventDefeatedStrongestHero << sep; ss << entry.movementPointsUsed; for(auto & resource : resources) - ss << ";" << entry.resources[resource]; + ss << sep << entry.resources[resource]; for(auto & resource : resources) - ss << ";" << entry.numMines[resource]; + ss << sep << entry.numMines[resource]; for(auto & resource : resources) - ss << ";" << entry.spentResourcesForArmy[resource]; + ss << sep << entry.spentResourcesForArmy[resource]; for(auto & resource : resources) - ss << ";" << entry.spentResourcesForBuildings[resource]; + ss << sep << entry.spentResourcesForBuildings[resource]; for(auto & resource : resources) - ss << ";" << entry.tradeVolume[resource]; + ss << sep << entry.tradeVolume[resource]; ss << "\r\n"; } return ss.str(); } +std::string StatisticDataSet::writeCsv() +{ + const boost::filesystem::path outPath = VCMIDirs::get().userCachePath() / "statistic"; + boost::filesystem::create_directories(outPath); + + const boost::filesystem::path filePath = outPath / (vstd::getDateTimeISO8601Basic(std::time(nullptr)) + ".csv"); + std::ofstream file(filePath.c_str()); + std::string csv = toCsv(";"); + file << csv; + + return filePath.string(); +} + std::vector Statistic::getMines(const CGameState * gs, const PlayerState * ps) { std::vector tmp; diff --git a/lib/gameState/GameStatistics.h b/lib/gameState/GameStatistics.h index daec71e4a..fafddeaa5 100644 --- a/lib/gameState/GameStatistics.h +++ b/lib/gameState/GameStatistics.h @@ -25,6 +25,7 @@ struct DLL_LINKAGE StatisticDataSetEntry time_t timestamp; int day; PlayerColor player; + std::string playerName; TeamID team; bool isHuman; EPlayerStatus status; @@ -52,6 +53,8 @@ struct DLL_LINKAGE StatisticDataSetEntry TResources spentResourcesForArmy; TResources spentResourcesForBuildings; TResources tradeVolume; + bool eventCapturedTown; + bool eventDefeatedStrongestHero; si64 movementPointsUsed; template void serialize(Handler &h) @@ -60,6 +63,8 @@ struct DLL_LINKAGE StatisticDataSetEntry h & timestamp; h & day; h & player; + if(h.version >= Handler::Version::STATISTICS_SCREEN) + h & playerName; h & team; h & isHuman; h & status; @@ -87,18 +92,22 @@ struct DLL_LINKAGE StatisticDataSetEntry h & spentResourcesForArmy; h & spentResourcesForBuildings; h & tradeVolume; + if(h.version >= Handler::Version::STATISTICS_SCREEN) + { + h & eventCapturedTown; + h & eventDefeatedStrongestHero; + } h & movementPointsUsed; } }; class DLL_LINKAGE StatisticDataSet { - std::vector data; - public: void add(StatisticDataSetEntry entry); static StatisticDataSetEntry createEntry(const PlayerState * ps, const CGameState * gs); - std::string toCsv(); + std::string toCsv(std::string sep); + std::string writeCsv(); struct PlayerAccumulatedValueStorage // holds some actual values needed for stats { @@ -112,6 +121,8 @@ public: TResources spentResourcesForBuildings; TResources tradeVolume; si64 movementPointsUsed; + int lastCapturedTownDay; + int lastDefeatedStrongestHeroDay; template void serialize(Handler &h) { @@ -125,8 +136,14 @@ public: h & spentResourcesForBuildings; h & tradeVolume; h & movementPointsUsed; + if(h.version >= Handler::Version::STATISTICS_SCREEN) + { + h & lastCapturedTownDay; + h & lastDefeatedStrongestHeroDay; + } } }; + std::vector data; std::map accumulatedValues; template void serialize(Handler &h) diff --git a/lib/networkPacks/PacksForClient.h b/lib/networkPacks/PacksForClient.h index e1e3e8b43..8d8cc4a92 100644 --- a/lib/networkPacks/PacksForClient.h +++ b/lib/networkPacks/PacksForClient.h @@ -24,6 +24,7 @@ #include "../gameState/RumorState.h" #include "../gameState/QuestInfo.h" #include "../gameState/TavernSlot.h" +#include "../gameState/GameStatistics.h" #include "../int3.h" #include "../mapping/CMapDefines.h" #include "../spells/ViewSpellInt.h" @@ -435,6 +436,7 @@ struct DLL_LINKAGE PlayerEndsGame : public CPackForClient PlayerColor player; EVictoryLossCheckResult victoryLossCheckResult; + StatisticDataSet statistic; void visitTyped(ICPackVisitor & visitor) override; @@ -442,6 +444,8 @@ struct DLL_LINKAGE PlayerEndsGame : public CPackForClient { h & player; h & victoryLossCheckResult; + if (h.version >= Handler::Version::STATISTICS_SCREEN) + h & statistic; } }; diff --git a/lib/serializer/ESerializationVersion.h b/lib/serializer/ESerializationVersion.h index 5517232b8..e0e9002f8 100644 --- a/lib/serializer/ESerializationVersion.h +++ b/lib/serializer/ESerializationVersion.h @@ -64,6 +64,7 @@ enum class ESerializationVersion : int32_t CAMPAIGN_REGIONS, // 853 - configurable campaign regions EVENTS_PLAYER_SET, // 854 - map & town events use std::set instead of bitmask to store player list NEW_TOWN_BUILDINGS, // 855 - old bonusing buildings have been removed + STATISTICS_SCREEN, // 856 - extent statistic functions - CURRENT = NEW_TOWN_BUILDINGS + CURRENT = STATISTICS_SCREEN }; diff --git a/server/CGameHandler.cpp b/server/CGameHandler.cpp index 52139e9e5..a1ae33d57 100644 --- a/server/CGameHandler.cpp +++ b/server/CGameHandler.cpp @@ -681,7 +681,7 @@ void CGameHandler::onPlayerTurnEnded(PlayerColor which) heroPool->onNewWeek(which); } -void CGameHandler::addStatistics() +void CGameHandler::addStatistics(StatisticDataSet &stat) const { for (const auto & elem : gs->players) { @@ -690,7 +690,7 @@ void CGameHandler::addStatistics() auto data = StatisticDataSet::createEntry(&elem.second, gs); - gameState()->statistic.add(data); + stat.add(data); } } @@ -718,6 +718,10 @@ void CGameHandler::onNewTurn() } } } + else + { + addStatistics(gameState()->statistic); // write at end of turn + } for (const auto & player : gs->players) { @@ -1036,8 +1040,6 @@ void CGameHandler::onNewTurn() } synchronizeArtifactHandlerLists(); //new day events may have changed them. TODO better of managing that - - addStatistics(); } void CGameHandler::start(bool resume) @@ -1414,6 +1416,8 @@ void CGameHandler::setOwner(const CGObjectInstance * obj, const PlayerColor owne const CGTownInstance * town = dynamic_cast(obj); if (town) //town captured { + gs->statistic.accumulatedValues[owner].lastCapturedTownDay = gs->getDate(Date::DAY); + if (owner.isValidPlayer()) //new owner is real player { if (town->hasBuilt(BuildingSubID::PORTAL_OF_SUMMONING)) @@ -3733,6 +3737,8 @@ void CGameHandler::checkVictoryLossConditionsForPlayer(PlayerColor player) PlayerEndsGame peg; peg.player = player; peg.victoryLossCheckResult = victoryLossCheckResult; + peg.statistic = StatisticDataSet(gameState()->statistic); + addStatistics(peg.statistic); // add last turn befor win / loss sendAndApply(&peg); turnOrder->onPlayerEndsGame(player); diff --git a/server/CGameHandler.h b/server/CGameHandler.h index 4ce8a0d85..f0f6bfbf5 100644 --- a/server/CGameHandler.h +++ b/server/CGameHandler.h @@ -14,6 +14,7 @@ #include "../lib/IGameCallback.h" #include "../lib/LoadProgress.h" #include "../lib/ScriptHandler.h" +#include "../lib/gameState/GameStatistics.h" VCMI_LIB_NAMESPACE_BEGIN @@ -227,7 +228,7 @@ public: void onPlayerTurnStarted(PlayerColor which); void onPlayerTurnEnded(PlayerColor which); void onNewTurn(); - void addStatistics(); + void addStatistics(StatisticDataSet &stat) const; void handleTimeEvents(PlayerColor player); void handleTownEvents(CGTownInstance *town); diff --git a/server/battles/BattleResultProcessor.cpp b/server/battles/BattleResultProcessor.cpp index 7b39a870e..b0b476e48 100644 --- a/server/battles/BattleResultProcessor.cpp +++ b/server/battles/BattleResultProcessor.cpp @@ -480,6 +480,17 @@ void BattleResultProcessor::endBattleConfirm(const CBattleInfoCallback & battle) // Remove beaten hero if(finishingBattle->loserHero) { + //add statistics + if(!finishingBattle->isDraw()) + { + ConstTransitivePtr strongestHero = nullptr; + for(auto & hero : gameHandler->gameState()->getPlayerState(finishingBattle->loser)->heroes) + if(!strongestHero || hero->exp > strongestHero->exp) + strongestHero = hero; + if(strongestHero->id == finishingBattle->loserHero->id && strongestHero->level > 5) + gameHandler->gameState()->statistic.accumulatedValues[finishingBattle->victor].lastDefeatedStrongestHeroDay = gameHandler->gameState()->getDate(Date::DAY); + } + RemoveObject ro(finishingBattle->loserHero->id, finishingBattle->victor); gameHandler->sendAndApply(&ro); } diff --git a/server/processors/PlayerMessageProcessor.cpp b/server/processors/PlayerMessageProcessor.cpp index 6ff2b93f5..e0198744c 100644 --- a/server/processors/PlayerMessageProcessor.cpp +++ b/server/processors/PlayerMessageProcessor.cpp @@ -140,15 +140,9 @@ void PlayerMessageProcessor::commandStatistic(PlayerColor player, const std::vec if(!isHost) return; - const boost::filesystem::path outPath = VCMIDirs::get().userCachePath() / "statistic"; - boost::filesystem::create_directories(outPath); + std::string path = gameHandler->gameState()->statistic.writeCsv(); - const boost::filesystem::path filePath = outPath / (vstd::getDateTimeISO8601Basic(std::time(nullptr)) + ".csv"); - std::ofstream file(filePath.c_str()); - std::string csv = gameHandler->gameState()->statistic.toCsv(); - file << csv; - - broadcastSystemMessage("Statistic files can be found in " + outPath.string() + " directory\n"); + broadcastSystemMessage("Statistic files can be found in " + path + " directory\n"); } void PlayerMessageProcessor::commandHelp(PlayerColor player, const std::vector & words) diff --git a/test/mock/mock_IGameInfoCallback.h b/test/mock/mock_IGameInfoCallback.h index 94939fc24..479f8656a 100644 --- a/test/mock/mock_IGameInfoCallback.h +++ b/test/mock/mock_IGameInfoCallback.h @@ -17,6 +17,7 @@ class IGameInfoCallbackMock : public IGameInfoCallback public: //various MOCK_CONST_METHOD1(getDate, int(Date)); + MOCK_CONST_METHOD1(getStartInfo, const StartInfo *(bool)); MOCK_CONST_METHOD1(isAllowed, bool(SpellID)); MOCK_CONST_METHOD1(isAllowed, bool(ArtifactID));