GameLogic.java

package io.github.unisim;

import com.badlogic.gdx.math.MathUtils;
import io.github.unisim.achievement.Achievement;
import io.github.unisim.achievement.AchievementManager;
import io.github.unisim.building.Building;
import io.github.unisim.building.BuildingType;
import io.github.unisim.event.*;
import io.github.unisim.world.World;

import java.util.function.BooleanSupplier;

/**
 * A class which manages the gameplay logic. This class is a new addition from the inherited codebase.
 */
public class GameLogic {
    // Time related constants.
    private static final int TOTAL_GAME_TIME = 5 * 60;
    private static final int ONE_YEAR_TIME = 100;
    private static final int SUMMER_TIME = 20;

    private static final int SLEEPING_BUILDING_STUDENT_COUNT = 20;
    private static final int STUDENT_TUITION_FEE = 1000;

    private final World world;
    private final AchievementManager achievementManager;
    private final EventManager eventManager;
    private GameState gameState;
    private float remainingTime;
    private int studentCount;
    private int money;
    private int lastTickedYear;

    // Satisfaction.
    private float satisfaction;
    private float newBuildingSatisfaction;

    public GameLogic(World world, Difficulty difficulty) {
        this(world, difficulty, () -> true);
    }

    public GameLogic(World world, Difficulty difficulty, BooleanSupplier canStartEvent) {
        this.world = world;
        achievementManager = new AchievementManager(this);
        eventManager = new EventManager(canStartEvent, this, difficulty);

        // Start in a paused state.
        gameState = GameState.PAUSED;
        remainingTime = TOTAL_GAME_TIME;
        money = difficulty.getStartingMoney();
    }

    /**
     * @return a score object for the current game state
     */
    public Score calculateScore() {
        // Calculate a final score based on the total value of placed buildings, ending satisfaction, and unlocked
        // achievements. Remaining money is not included in order to incentivise building placement.
        final int campusValue = world
            .getBuildingManager()
            .getBuildings()
            .stream()
            .mapToInt(b -> b.price)
            .sum();
        return new Score(
            campusValue,
            satisfaction,
            achievementManager.getUnlockedAchievements().stream().toList()
        );
    }

    /**
     * @param building a building
     * @return the price of the building including any discount
     */
    public int getBuildingPrice(Building building) {
        int buildingPrice = building.price;
        if (eventManager.getCurrentEvent() instanceof DiscountEvent discountEvent) {
            buildingPrice = discountEvent.applyDiscount(buildingPrice);
        }
        return buildingPrice;
    }

    /**
     * Places the currently selected building.
     *
     * @return true if the building was placed; false if not
     */
    public boolean placeBuilding() {
        int buildingPrice = getBuildingPrice(world.selectedBuilding);
        if (money < buildingPrice) {
            return false;
        }
        if (!world.placeBuilding()) {
            return false;
        }
        money -= buildingPrice;

        // Calculate new building satisfaction.
        newBuildingSatisfaction += Math.min(0.25f / (float) Math.pow(satisfaction, 0.5f), 1.0f);
        return true;
    }

    /**
     * Continuously updates the student satisfaction.
     *
     * @param deltaTime the delta time between the last call of updateSatisfaction
     */
    private void updateSatisfaction(float deltaTime) {
        // Slowly apply new building satisfaction.
        float newBuildingFactor = newBuildingSatisfaction * deltaTime;
        satisfaction += newBuildingFactor;
        newBuildingSatisfaction -= newBuildingFactor;
        newBuildingSatisfaction = Math.max(newBuildingSatisfaction, 0.0f);

        // Decrease satisfaction if there isn't enough eating or learning buildings for all the students. Each restaurant
        // can support 75 students and each library building can support 125 students. Use exponential formulas so a
        // deficit can not just be offset by placing lots of recreation buildings.
        var eatingDeficit = studentCount - world.getBuildingCount(BuildingType.EATING) * 75;
        var learningDeficit = studentCount - world.getBuildingCount(BuildingType.LEARNING) * 125;
        if (eatingDeficit > 0) {
            satisfaction -= ((float) Math.pow(2.0f, eatingDeficit / 12.0f) / 175.0f) * deltaTime * 0.4f;
        }
        if (learningDeficit > 0) {
            satisfaction -= ((float) Math.pow(2.0f, learningDeficit / 15.0f) / 75.0f) * deltaTime * 0.3f;
        }

        // Decay satisfaction over time, but lower the factor based on the amount of recreation buildings.
        float decayRate = 0.02f;
        decayRate -= world.getBuildingCount(BuildingType.RECREATION) / 250.0f;
        satisfaction -= Math.max(decayRate, 0.0015f) * deltaTime;

        // Apply any satisfaction changes from the current event.
        if (eventManager.getCurrentEvent() instanceof SatisfactionEvent satisfactionEvent) {
            satisfaction += satisfactionEvent.getSatisfaction(deltaTime);
        }

        // Clamp satisfaction between zero and one.
        satisfaction = MathUtils.clamp(satisfaction, 0.0f, 1.0f);
    }

    /**
     * Updates the game logic.
     *
     * @param deltaTime the delta time between the last call of update
     */
    public void update(float deltaTime) {
        // Move to GAME_OVER state when timer runs out.
        if (remainingTime <= 0.0f) {
            gameState = GameState.GAME_OVER;
        }

        if (gameState == GameState.PAUSED || gameState == GameState.GAME_OVER) {
            return;
        }

        // Tick timer down.
        remainingTime -= deltaTime;

        // Calculate student count.
        studentCount = world.getBuildingCount(BuildingType.SLEEPING) * SLEEPING_BUILDING_STUDENT_COUNT;

        // Add money if year has ticked. Each student brings in their yearly tuition fee.
        final int year = getYear();
        if (lastTickedYear != year && !isSummer()) {
            final int tuitionIncome = studentCount * STUDENT_TUITION_FEE;
            money += tuitionIncome;
            lastTickedYear = year;

            // Add money from building passive income.
            for (var building : world.getBuildingManager().getBuildings()) {
                money += building.passiveIncome;
            }
        }

        // Update satisfaction.
        updateSatisfaction(deltaTime);

        // Update achievements and events.
        achievementManager.update(deltaTime);
        eventManager.update(deltaTime);

        // Handle busy week event.
        var peopleManager = world.getPeopleManager();
        if (peopleManager != null) {
            if (eventManager.getCurrentEvent() instanceof BusyWeekEvent) {
                peopleManager.setSpawnRateMultiplier(20.0f);
            } else {
                peopleManager.setSpawnRateMultiplier(1.0f);
            }
        }

        // Handle donation event.
        if (eventManager.getCurrentEvent() instanceof DonationEvent donationEvent) {
            money += donationEvent.getMoney();
        }
    }

    /**
     * Pauses the game if currently in the playing state.
     */
    public void pause() {
        if (gameState == GameState.PLAYING) {
            gameState = GameState.PAUSED;
        }
    }

    /**
     * Unpauses the game.
     */
    public void unpause() {
        if (gameState == GameState.PAUSED) {
            gameState = GameState.PLAYING;
        }
    }

    public int getSatisfactionPercentage() {
        return MathUtils.ceil(satisfaction * 100.0f);
    }

    public Achievement getRecentlyUnlockedAchievement() {
        return achievementManager.getRecentlyUnlockedAchievement();
    }

    public int getYear() {
        return (int) (TOTAL_GAME_TIME - remainingTime - 0.5f) / ONE_YEAR_TIME + 1;
    }

    public int getSecondsIntoYear() {
        return (int) (TOTAL_GAME_TIME - remainingTime - 0.5f) % ONE_YEAR_TIME;
    }

    public int getSemester() {
        if (getSecondsIntoYear() >= (ONE_YEAR_TIME / 2 + SUMMER_TIME / 2)) {
            return 2;
        }
        return 1;
    }

    public boolean isSummer() {
        return getSecondsIntoYear() < SUMMER_TIME;
    }

    public boolean isPaused() {
        return gameState == GameState.PAUSED;
    }

    public boolean isGameOver() {
        return gameState == GameState.GAME_OVER;
    }

    public AchievementManager getAchievementManager() {
        return achievementManager;
    }

    public EventManager getEventManager() {
        return eventManager;
    }

    public float getRemainingTime() {
        return remainingTime;
    }

    public int getStudentCount() {
        return studentCount;
    }

    public int getMoney() {
        return money;
    }

    public float getSatisfaction() {
        return satisfaction;
    }
}