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.
The programme prompts the user if there is local data available to load from.
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.
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.
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!
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
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.");
}
}
}
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
}
}
}
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);
}
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);
}
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...
}
}
Factory Pattern Implementation
The project uses the Factory Pattern. This pattern provides several benefits:
- Encapsulation: By centralising object creation logic, we hide complex instantiation details from the client code. Also reduces code duplication.
- Flexibility: New Pokémon types can be added without modifying existing code, following the Open-Closed Principle (open for extension, closed for modification).
- 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
);
}
}
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();
}
};
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);
}
}
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...
}
HashMap vs LinkedHashMap Note:
For user data storage, I chose 'LinkedHashMap' over 'HashMap' because:
- Order Preservation:
- User data needs to maintain insertion order for display
- LinkedHashMap maintains order with minimal overhead
- 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...
}
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.");
}
}
}
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."
));
}
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.");
}
}
}
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:
State Management: Each Pokémon's state (health, moves, status effects) needed to be tracked and updated correctly throughout the battle.
-
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
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);
}
}
- 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();
}
}
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);
}
}
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));
}
}
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)