I found an awesome sprite sheet collection that included a bunch of
stuff I could use for generating some animated sprites for my generated DnD character I made in
Part 1. My swagger spec shows that this endpoint will take in a CharacterInfo
, and based
on those choices I can walk the sprite sheet folder and pick the appropriate body, weapons, and armor. Now the tricky part
is how to easily define which images to select given some condition in the provided CharacterInfo
. Since I already have
a fancy Choice
abstraction, I decided to just extend that a bit.
AllowedPaths
What this ultimately boils down to is a valid condition based on some value in the CharacterInfo
, then make a series of
choices based on that. For example, if the character has Leather
in their equipment, then they should pick a some leather
torso and arm armor:
{
"conditions": [
{
"equipment": [
"Leather"
]
}
],
"choices": [
{
"choose": 1,
"from": [
"sheets/torso/leather/chest_{{gender}}.png",
"sheets/torso/leather/shoulders_{{gender}}.png"
]
},
{
"choose": 1,
"from": [
"sheets/hands/bracers/{{gender}}/leather_bracers_{{gender}}.png"
]
}
]
}
Note: I have a special {{gender}}
entry in there that I will replace with a randomly selected gender when I actually
create the sprite sheet.
The above JSON will check and see if the equipment of the character contains Leather
, then it will choose one piece of
torso armor and one piece of arm armor. I will expand on this to be a list of choices like this, where the order will be
the layer of the PNG. So I will select the body first, then some facial features and hair, then clothes, then weapons.
See the full JSON definition here.
This can be translated into this Java model:
public class AllowedPaths {
public enum InfoAttribute {
@JsonProperty("equipment")
EQUIPMENT,
@JsonProperty("race")
RACE,
@JsonProperty("gender")
GENDER;
}
private List<Map<InfoAttribute, List<String>>> conditions;
private List<Choice<String>> choices;
public List<Map<InfoAttribute, List<String>>> getConditions() {
return conditions;
}
public List<Choice<String>> getChoices() {
return choices;
}
}
Sprite Subscriber (Flow API)
Now that I have an abstraction for getting all of the paths I need to overlay for the final sprite sheet, it’s time for
some more unnecessary asynchronous calls! For this I’m going to play around with the
Flow API since I have never used it before. This
is a pretty terrible use case for this since order is incredibly important, but in my testing it didn’t turn out horribly
so I just stuck with it. At some point I might add a layer
concept into the abstraction so I can build all the layers
then merge them all together at the very end.
I’m going to make a simple subscriber that expects to be handed a list of paths that it needs to fetch and overlay. I also
want it to be able to report when it is done. I could do that by having it utilize a CompletableFuture
, but I decided
instead to use a CountdownLatch
since I haven’t used them much and I know how many times I’m going to run this upfront.
To initialize my subscriber, I just create an empty image of the size of the final sprite sheet, and save the latch I will
use to mark when the subscriber has finished:
public class SpriteSubscriber implements Flow.Subscriber<Set<String>> {
private static final Logger LOGGER = LoggerFactory.getLogger(SpriteSubscriber.class);
private static final int WIDTH = 832;
private static final int HEIGHT = 1344;
private final BufferedImage spriteSheet;
private final CountDownLatch latch;
private Flow.Subscription subscription;
public SpriteSubscriber(CountDownLatch latch) {
this.spriteSheet = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
this.latch = latch;
}
public BufferedImage getSpriteSheet() {
return spriteSheet;
}
}
This class is going to need to construct the actual file paths for each file, and also fetch them as a BufferedImage
so I
can overlay them, which means I need some simple helper methods:
private URI getFilePath(String fileLocation) {
try {
return getClass().getResource(fileLocation)
.toURI();
} catch (Exception e) {
LOGGER.error(String.format("Failed to construct file path: %s", fileLocation), e);
throw new RuntimeException(e);
}
}
private BufferedImage readImage(String fileLocation) throws IOException {
return ImageIO.read(new File(getFilePath(fileLocation)));
}
And I also need a method that will overlay transparent images on the base image I created, which was surprisingly simple:
private void overlayPaths(Set<String> paths) throws IOException {
if (paths.isEmpty()) {
return;
}
Graphics2D g = this.spriteSheet.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
for (String path : paths) {
BufferedImage image = readImage(path);
g.drawImage(image, 0, 0, null);
}
g.dispose();
}
Finally I am ready to implement the Flow.Subscriber
methods:
@Override
public void onSubscribe(Flow.Subscription subscription) {
this.subscription = subscription;
subscription.request(1);
}
@Override
public void onNext(Set<String> paths) {
try {
overlayPaths(paths);
latch.countDown();
subscription.request(1);
} catch (IOException e) {
LOGGER.error("Failed to overlay paths", e);
}
}
@Override
public void onError(Throwable throwable) {
LOGGER.error("Failed to process message", throwable);
}
@Override
public void onComplete() {
LOGGER.info("Finished building sprite sheet");
}
The onSubscribe()
function will immediately request the first result, since there is no use waiting. Then for each set of
paths (onNext()
), it will overlay the paths, tell the latch is has completed a task, and request the next batch of paths.
Whenever an error occurs, we will just log it. This means that we may end up with a weird sprite sheet missing some layers,
or just an entirely blank one. Not ideal error handling, but it might yield some interesting character designs so I’m just
leaving it as is for now. Finally, whenever the publisher says it is done we log it.
Sprite Builder
Now I’m ready to actually build the sprite sheet. This will require
- Determining which paths should be used for the images
- Overlaying said images
Determine Allowed Paths
In order to determine the allowed paths, I need a bit of stuff up front:
@Component
public class SpriteBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(SpriteBuilder.class);
private static final List<String> GENDERS = ImmutableList.of("male", "female");
private static final Random RANDOM = new Random();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(10);
private static final List<AllowedPaths> ALLOWED_PATHS;
static {
List<AllowedPaths> allowedPaths = Collections.emptyList();
try {
allowedPaths = OBJECT_MAPPER.readValue(SpriteBuilder.class.getResourceAsStream("allowedPaths.json"),
new TypeReference<>() {});
} catch (Exception e) {
LOGGER.error("Failed to load allowed paths mapping file", e);
}
ALLOWED_PATHS = allowedPaths;
}
}
This setup will allow selecting a random gender, determining the full list of paths I could select from, and initializes
an ExecutorService
I can use for the async publisher I will use to tell my fancy subscriber it has work to do. Now that I
have that setup, I can make my function to determine allowed paths based on the provided CharacterInfo
. I will need to
know the race, list of equipment, and the gender for the character:
private Set<String> evaluateAllowedPaths(AllowedPaths allowedPaths,
String race,
List<String> equipmentNames,
String gender) {
// Treat conditions as ANDs
for (Map<AllowedPaths.InfoAttribute, List<String>> condition : allowedPaths.getConditions()) {
if (condition == null) {
continue;
}
List<String> equipmentConditions = condition.get(AllowedPaths.InfoAttribute.EQUIPMENT);
if (equipmentConditions != null && !CollectionUtils.containsAny(equipmentConditions, equipmentNames)) {
return Collections.emptySet();
}
List<String> raceConditions = condition.get(AllowedPaths.InfoAttribute.RACE);
if (raceConditions != null && !raceConditions.contains(race)) {
return Collections.emptySet();
}
List<String> genderConditions = condition.get(AllowedPaths.InfoAttribute.GENDER);
if (genderConditions != null && !genderConditions.contains(gender)) {
return Collections.emptySet();
}
}
Set<String> paths = new HashSet<>();
for (Choice<String> pathChoice : allowedPaths.getChoices()) {
paths.addAll(ChoiceUtils.makeRandomChoices(pathChoice));
}
return paths.stream()
.map(path -> path.replace("{{gender}}", gender))
.collect(Collectors.toSet());
}
The first loop just explicitly calls out the possible conditions and checks them. Everything within the list provided
in the condition is valid, so it is an OR
query, where as if there are multiple conditions, all must be true
(AND
query). Once I confirm the given condition is satisfied, I can just make a random choice, do my special
string replacement to ensure the gender
is accurate for all choices, and go on my way.
Now I am ready for the meat and potatoes of this work: coordinating the flow. This requires creating an instance of the sprite subscriber defined above, as well as a publisher that will call out every time a batch of paths is ready (a new layer). Since I know how many possible layers there are, I can just create a countdown latch based on that so the subscriber can inform me when it is finished:
public byte[] buildSpriteSheet(CharacterInfo characterInfo) throws IOException {
String race = characterInfo.getRace();
List<String> equipmentNames = characterInfo.getEquipment()
.stream()
.map(Equipment::getName)
.collect(Collectors.toList());
String gender = GENDERS.get(RANDOM.nextInt(2));
SubmissionPublisher<Set<String>> publisher = new SubmissionPublisher<>(EXECUTOR, 10);
CountDownLatch countDownLatch = new CountDownLatch(ALLOWED_PATHS.size());
SpriteSubscriber spriteSubscriber = new SpriteSubscriber(countDownLatch);
publisher.subscribe(spriteSubscriber);
for (AllowedPaths allowedPaths : ALLOWED_PATHS) {
Set<String> paths = evaluateAllowedPaths(allowedPaths, race, equipmentNames, gender);
publisher.offer(paths,
200,
TimeUnit.MILLISECONDS,
(subscriber, strings) -> {
subscriber.onError(new RuntimeException("Dropped message"));
return false;
});
}
try {
countDownLatch.await();
publisher.close();
return toByteArray(spriteSubscriber.getSpriteSheet());
} catch (InterruptedException e) {
Thread.currentThread()
.interrupt();
return null;
}
}
private byte[] toByteArray(BufferedImage bufferedImage) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, "png", baos);
return baos.toByteArray();
}
The most confusing part is the offer()
call on the publisher. That one takes in the following parameters:
- The message payload. In this case, a batch of paths representing a layer of the sprite sheet.
- The timeout for the process to finish before dropping the message.
- The time unit for the timeout.
- A function to execute when a message is dropped. My subscriber will just log it in my current implementation, and I
would just leave that layer out. I also return
false
in the function to indicate the publisher should not retry the message, since that could have worse results.
Get Sprite Sheet API
Now I can implement the API method to return our brand spankin' new sprite sheet:
@RequestMapping("${openapi.characterCreator.base-path:/character-builder/v1}")
public class SpriteApiController implements SpriteApi {
private static Logger LOGGER = LoggerFactory.getLogger(SpriteApiController.class);
private final NativeWebRequest request;
private final SpriteBuilder spriteBuilder;
@Autowired
public SpriteApiController(NativeWebRequest request, SpriteBuilder spriteBuilder) {
this.request = request;
this.spriteBuilder = spriteBuilder;
}
@Override
public Optional<NativeWebRequest> getRequest() {
return Optional.ofNullable(request);
}
@Override
public ResponseEntity<byte[]> getSpriteSheet(CharacterInfo characterInfo) {
try {
return ResponseEntity.ok(spriteBuilder.buildSpriteSheet(characterInfo));
} catch (Exception e) {
LOGGER.error("Failed to generate sprite sheet", e);
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
Dockerize
Now that I have all my server code done, I can make a simple Dockerfile to just run the command I was using for local development:
FROM maven:3-adoptopenjdk-11
COPY pom.xml pom.xml
COPY src src/
RUN mvn clean package
EXPOSE 8080
ENTRYPOINT mvn package spring-boot:run
And I’m all done! The server is officially complete. However, making a sprite sheet is no fun if I can’t see it in action. And having to parse a bunch of JSON to fill out my character sheet sucks. As much as I am a backend fanboy, the frontend is where this work will really shine. Although it pains me to say it. Turns out though, I had a lot of fun making the frontend for this app, check it out in Part 3.