DEV Community

Matt Johnston
Matt Johnston

Posted on • Edited on

Devlog: Developing a Java-Based Interactive Digital Artefact

Welcome to this behind-the-scenes look into the development of my interactive Java-based digital artefact! This project combines various programming techniques and design elements to create a console application, core functionality including:

Required features:

  • A looping menu system
  • Console output
  • User input
  • Selection (If, If-else, Switch)
  • Loops
  • Methods
  • Arrays
  • Testing
  • Objects

Additional features implemented:

  • File read/write (saving user data; name, age, place of work etc. this can then be loaded the next time the programme is run if there is a valid file available). The selected menu options whilst the programme is running are also saved, as well as a log of battle and the results of the TDEE calculator.

Image description
The programme prompts the user if there is local data available to load from.
Image description
Once loaded the user now has access to the menu system. NOTE: If a user chooses not to load local data, then a new set of user data will be created and optionally stored on programme exit.
Image description
Here is how the user data looks like stored in a .txt file after a successful run through the menu system.

  • A Pokémon-inspired battle system with a 'real-time' health bar indicator, multiple moves to choose from and the option to heal and surrender. This battle system also saves a log of recent battles as shown below. Image description

Image description
Here is the menu system I created for the Pokémon battle system, as we were limited as to what we could use, as to stay within the scope of the module, I had to get creative with the UI!

Image description
This screenshot just demonstrates that the health bars for both Pokémon do update after each turn.

  • A TDEE calculator (Total Daily Energy Expenditure)
  • User personalisation

This was my first time touching Java and my first real stab at building something following OOP (Object Oriented Programming) principles, so I'm happy with the end result, but also the learning opportunities it provided for me along the way.

Development Approach

Before diving into specific features, I want to highlight my overall development approach. The entire project is version controlled using Git, following industry best practices with clear commit messages etc. This not only helped track my progress but also made it easier to experiment with new features without risking the stable codebase.

The codebase follows clean code principles throughout:

  • Clear and consistent naming conventions
  • Comprehensive JavaDoc documentation
  • Well-structured package organisation
  • Unit test coverage
  • Single Responsibility Principle adherence

View on GitHub 🔗

I also took this as an opportunity to use a new (to me) IDE - IntelliJ IDEA. It was extremely useful for ensuring a clean file structure and implementing JUnit for my testing.

Section 1: Features Implemented

Feature 1: User Input and Validation

User input and proper validation were integral to making this application interactive and user-friendly on any level, without it just breaking. A clear example of this can be seen in the Main class, where I collect user details at the start of the program.

public static String getStringInput(String prompt, Scanner scanner) {
    System.out.println(prompt);
    return scanner.nextLine().trim();
}

public static int getIntInput(String prompt, Scanner scanner) {
    while (true) {
        System.out.println(prompt);
        try {
            return Integer.parseInt(scanner.nextLine().trim());
        } catch (NumberFormatException e) {
            System.out.println("Invalid input. Please enter a whole number.");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

These methods ensure that the user input is validated. For instance, getIntInput keeps prompting the user until a valid integer is provided. This approach prevents crashes at runtime caused by invalid input and improves overall 'robustness'.

In the TDEE class, I added additional range validation to ensure that inputs like 'age', and 'weight' were realistic. Here's how I did it:

public int promptForInt(String prompt, int min, int max) {
    int value;
    while (true) {
        System.out.print(prompt);
        if (sc.hasNextInt()) {
            value = sc.nextInt();
            if (value >= min && value <= max) {
                sc.nextLine();  // Consume newline
                return value;
            } else {
                System.out.println("Please enter a value between " + min + " and " + max + ".");
            }
        } else {
            System.out.println("Invalid input. Please enter a valid integer.");
            sc.nextLine();  // Consume invalid input
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that inputs were not only integers but also fell within a meaningful range, enhancing user experience and data integrity.

Feature 2: Loops for Repetition and Flow Control

Loops are a cornerstone of the program's design, especially pivotal in the menu systems. Both the MenuSystem and TDEE classes rely heavily on loops to keep the program running until the user makes the decision to exit.

For example, the main menu loop in the MenuSystem class looks like this:

while (userContinue) {
    System.out.printf("""
        In this programme you have %d choices:
        -------------------------------------
        """, menuOptions.length);

    for (String option : menuOptions) {
        System.out.println(option);
    }
    System.out.printf("\nPlease pick (1-%d):", menuOptions.length);

    // Get and process user selection
    int menuOption = sc.nextInt();
    sc.nextLine();  // Consume newline
    menuHistory.add(menuOption);

    switch (menuOption) {
        case 1 -> System.out.printf("You have %d years to retirement!\n", 35 - yearsOfWork);
        case 2 -> System.out.printf("You work at %s\n", placeOfWork);
        case 3 -> System.out.printf("Your full name is: %s %s\n", firstName, lastName);
        default -> System.out.println("Invalid option.");
    }

    userContinue = checkContinue(sc, menuHistory);
}
Enter fullscreen mode Exit fullscreen mode

The loop ensures that the user can keep interacting with the menu without restarting the application. It also tracks the selected options for saving later.

In the PokemonBattler class, loops were used to handle player and computer turns dynamically until the battle concluded:

while (player.isAlive() && computer.isAlive()) {
    displayBattleStatus(player, computer);
    handlePlayerTurn(player, computer);
    if (!computer.isAlive()) break;
    handleComputerTurn(computer, player);
}
Enter fullscreen mode Exit fullscreen mode

This loop guarantees that the battle continues until either the player or computer's Pokémon faints, or the user surrenders the battle. This ensures smooth and predictable gameplay.

Feature 3: Advanced Object-Oriented Design

The project leverages several OOP principles:

Inheritance and Polymorphism

The Pokémon battle system demonstrates inheritance through an abstract Pokemon base class with specialised subclasses:

public class FirePokemon extends Pokemon {
    @Override
    public void initialiseMoves() {
        moves.add(new Move(
            "Fire Blast",
            20,
            Constants.MEDIUM_ACCURACY,
            "A powerful blast of fire"
        ));
        // Additional moves...
    }
}
Enter fullscreen mode Exit fullscreen mode

Factory Pattern Implementation

The project uses the Factory Pattern. This pattern provides several benefits:

  1. Encapsulation: By centralising object creation logic, we hide complex instantiation details from the client code. Also reduces code duplication.
  2. Flexibility: New Pokémon types can be added without modifying existing code, following the Open-Closed Principle (open for extension, closed for modification).
  3. Validation: The factory can enforce "business rules" during object creation. Meaning it provides type checking and error handling for the object creation.

I considered alternative approaches like:

  • Simple constructors (i've already used this elsewhere in the project so I wanted to branch out and try something new)
  • Builder pattern (overly complex for our needs)
  • Prototype pattern (unnecessary object cloning overhead)

Here's the implementation:

public abstract class Pokemon {
    // Factory method for creating Pokémon instances
    protected static Pokemon createPokemon(String name, String type, 
            int health, int attackPower, String description, 
            Class<? extends Pokemon> pokemonClass) {
        try {
            // Use reflection to create the specific Pokémon type
            Constructor<?> constructor = pokemonClass.getDeclaredConstructor(
                String.class, int.class, int.class);
            return (Pokemon) constructor.newInstance(name, health, attackPower);
        } catch (Exception e) {
            throw new RuntimeException("Failed to create Pokemon: " + e.getMessage());
        }
    }
}

public class FirePokemon extends Pokemon {
    // Factory method implementation for FirePokemon
    public static FirePokemon create(String name, int health, int attackPower) {
        return (FirePokemon) Pokemon.createPokemon(
            name,
            "Fire",
            health,
            attackPower,
            "A powerful Fire-type Pokemon that breathes scorching flames.",
            FirePokemon.class
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

While this implementation works well for my current needs in this project, in a larger system I would consider:

  • Adding validation logic in the factory
  • Implementing a registry for Pokémon types

Feature 4: Advanced Battle System Implementation

Note: As the required features of this programme, user data collection, a working menu system etc. didn't, in my opinion, really provide enough scope to work in any new learning points, I spent most of my time developing the battle system for the Pokémon option.

The battle system demonstrates several advanced Java features and OOP principles:

Sophisticated Flow Control

The battle system uses a yield statement within a switch expression, showcasing modern Java features:

return switch (choice) {
    case 1 -> FirePokemon.create("Charizard", 100, 20);
    case 2 -> WaterPokemon.create("Blastoise", 100, 20);
    case 3 -> GrassPokemon.create("Venusaur", 140, 10);
    default -> {
        System.out.println("Invalid choice. Please choose 1-3:");
        yield choosePokemon();
    }
};
Enter fullscreen mode Exit fullscreen mode

This approach not only makes the code more concise but also ensures exhaustive handling of all cases. The yield keyword is particularly powerful here as it allows for multiple statements in the default case while maintaining the expression-based nature of the switch.

Intelligent Computer Opponent

The computer opponent implementation showcases strategic decision-making:

private void handleComputerTurn(Pokemon computer, Pokemon player) {
    System.out.println("\nOpponent's turn!");

    if (computer.getHealth() < 40 && computer.hasHeals() && Math.random() < 0.7) {
        computer.computerHeal();
        addCommentary(computer.name + " used a healing move!");
    } else {
        executeComputerMove(computer, player);
    }
}
Enter fullscreen mode Exit fullscreen mode

Feature 5: Data Management and Persistence

The project includes sophisticated data handling through the DataLoader and WriteFile classes:

public static Map<String, String> loadMostRecentUserData() {
    // Using LinkedHashMap for ordered data storage
    Map<String, String> userData = new LinkedHashMap<>();
    // Implementation details...
}
Enter fullscreen mode Exit fullscreen mode

HashMap vs LinkedHashMap Note:

For user data storage, I chose 'LinkedHashMap' over 'HashMap' because:

  1. Order Preservation:
  • User data needs to maintain insertion order for display
  • LinkedHashMap maintains order with minimal overhead
  1. Performance Characteristics:
  • Slightly higher memory usage (acceptable for small datasets)
  • Minimal impact on insertion/retrieval performance
  • Better iteration performance for ordered data

Not that any of these points really apply as the dataset(s) that i'm working with in this project are so small, however the consideration still provided a good learning opportunity for me.

Feature 6: Constants Management

Following the Single Source of Truth principle, I centralised all constants in a dedicated class:

public final class Constants {
    private Constants() {
        throw new UnsupportedOperationException("This utility class should not be instantiated");
    }

    // Game Constants
    public static final int MAX_HEALTH = 100;
    public static final double TYPE_ADVANTAGE_MULTIPLIER = 1.1;
    // Additional constants...
}
Enter fullscreen mode Exit fullscreen mode

Section 2: Challenges and Learning Points

Challenge 1: User Input Edge Cases

Initially, I had issues with the user input validation. For example, entering a non-numeric value where an integer was expected caused the program to crash. The solution was to wrap input parsing in a try-catch block and provide continuous prompts until valid input was received:

public static int getIntInput(String prompt, Scanner scanner) {
    while (true) {
        System.out.println(prompt);
        try {
            return Integer.parseInt(scanner.nextLine().trim());
        } catch (NumberFormatException e) {
            System.out.println("Invalid input. Please enter a whole number.");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Challenge 2: Managing Pokémon Moves

Another challenge was managing Pokémon moves effectively, especially ensuring accuracy was calculated correctly. Initially, the moves always hit, which made the gameplay less dynamic and a bit drab. In order to fix this, I implemented an accuracy property for each move. These are specified in the specialised subclasses like so:

@Override
public void initialiseMoves() {
    moves.add(new Move(
            "Leaf Storm",    // High power, medium accuracy
            20,
            Constants.MEDIUM_ACCURACY,
            "A powerful hurricane of leaves!"
    ));
    moves.add(new Move(
            "Leaf Blade",    // Lower power, high accuracy
            15,
            Constants.HIGH_ACCURACY,
            "A weak but accurate leaf attack"
    ));
    moves.add(new Move(
            "Solar Beam",    // Highest power, lowest accuracy
            25,
            Constants.LOW_ACCURACY,
            "A devastating but inaccurate attack."
    ));
}
Enter fullscreen mode Exit fullscreen mode

This obviously adds a probability factor to attacks, and a little risk-reward ratio when trying to use a stronger move. Makes the battles a bit more engaging and unpredictable.

Challenge 3: File Operations

Handling file operations safely required careful error handling and proper resource management:

private void createOutputDirectory() {
    File directory = new File(Constants.OUTPUT_DIR);
    if (!directory.exists()) {
        boolean created = directory.mkdirs();
        if (!created) {
            System.err.println("Failed to create output directory.");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Challenge 4: Battle System Complexity

Implementing the battle system presented several challenges, particularly around state management and turn flow. The solution required careful decisions and some resaonably thorough planning. I broke this down into several key components:

  1. State Management: Each Pokémon's state (health, moves, status effects) needed to be tracked and updated correctly throughout the battle.

  2. Turn Flow Control: The battle system needed to gracefully handle alternating turns between player and computer, including:

    • Move selection and validation
    • Damage calculation with type effectiveness
    • Status updates and battle commentary
    • Win/loss condition checking
  3. Computer Logic: The computer opponent needed to make somewhat intelligent decisions. I implemented this through a combination of randomisation and strategic logic:

private void handleComputerTurn(Pokemon computer, Pokemon player) {
    // Strategic healing when health is low
    if (computer.getHealth() < 40 && computer.hasHeals() && Math.random() < 0.7) {
        computer.computerHeal();
    } else {
        executeComputerMove(computer, player);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Battle History: Maintaining a running commentary of the battle required careful consideration of data structures:
private final List<String> battleCommentary = new ArrayList<>();
private void addCommentary(String comment) {
    battleCommentary.add(comment);
    if (battleCommentary.size() > Constants.MAX_BATTLE_HISTORY) {
        battleCommentary.removeFirst();
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Strategy

I implemented a comprehensive testing strategy using JUnit 5, the latest version of the popular Java testing framework. Each major component of the application has its own test suite, ensuring functionality at both the unit and integration level. Here are some key examples:

Pokémon Type Testing

The FirePokemonTest class verifies the correct creation and behaviour of Fire-type Pokémon:

class FirePokemonTest {
    @Test
    void testFirePokemonCreation() {
        FirePokemon firePokemon = FirePokemon.create("Charizard", 100, 20);
        assertEquals("Charizard", firePokemon.name);
        assertEquals("Fire", firePokemon.type);
    }
}
Enter fullscreen mode Exit fullscreen mode

Battle System Testing

The PokemonBattlerTest class ensures the battle system functions correctly:

class PokemonBattlerTest {
    @Test
    void testGenerateOpponent() {
        PokemonBattler battler = new PokemonBattler();
        FirePokemon playerPokemon = FirePokemon.create("Charizard", 100, 20);
        assertNotNull(battler.generateOpponent(playerPokemon));
    }
}
Enter fullscreen mode Exit fullscreen mode

This test suite proves that the application's core features work as intended. I focused on testing:

  • Object creation and initialisation
  • Battle system mechanics
  • Calculation accuracy
  • Input validation
  • Data persistence

While the test coverage isn't 100% (as some parts involve random elements or user input), I've ensured that all critical paths are tested to some degree. This testing strategy has helped catch several bugs early in development and made refactoring much safer.

Conclusion

This project has been an amazing journey into Java and OOP principles. While it started as a simple console application, it evolved into something much more complex and interesting - especially the Pokémon battle system, which let me experiment with modern Java features and design patterns.

The biggest takeaways? Proper planning saves time (looking at you, battle system), clean code is worth the effort, and testing is absolutely crucial. Even though this was my first Java project, following industry best practices like version control, documentation, and SOLID principles helped keep the code maintainable and extensible.

View the Complete Source Code on GitHub 🔗

Sure, there's room for improvement - maybe adding more Pokémon types or implementing a save system - but for a first dive into Java development, I'm pretty happy with how it turned out. Most importantly, I've built a solid foundation in OOP that I'll definitely be building on in future projects.

Top comments (0)