Skip to content

Commit

Permalink
Custom tileset loader support.
Browse files Browse the repository at this point in the history
Closes Hexworks#386.
  • Loading branch information
nanodeath committed May 24, 2021
1 parent 29346eb commit cccbc22
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import org.hexworks.zircon.api.color.TileColor
import org.hexworks.zircon.api.component.ColorTheme
import org.hexworks.zircon.api.data.Size
import org.hexworks.zircon.api.resource.TilesetResource
import org.hexworks.zircon.api.tileset.TilesetLoader
import org.hexworks.zircon.internal.renderer.Renderer
import kotlin.jvm.JvmStatic

/**
Expand Down Expand Up @@ -121,7 +123,12 @@ data class AppConfig(
/**
* If set, contains custom properties that plugin authors can set and access.
*/
internal val customProperties: Map<AppConfigKey<*>, Any> = emptyMap()
internal val customProperties: Map<AppConfigKey<*>, Any> = emptyMap(),
/**
* If set [tilesetLoaders] will contain the list of [TilesetLoaders][TilesetLoader] to try to use
* before using the default [TilesetLoader] of the [Renderer].
*/
val tilesetLoaders: List<TilesetLoader<*>> = emptyList()
) {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.hexworks.zircon.api.builder.Builder
import org.hexworks.zircon.api.color.TileColor
import org.hexworks.zircon.api.data.Size
import org.hexworks.zircon.api.resource.TilesetResource
import org.hexworks.zircon.api.tileset.TilesetLoader
import org.hexworks.zircon.internal.config.RuntimeConfig
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic
Expand Down Expand Up @@ -212,6 +213,18 @@ data class AppConfigBuilder(
)
}

/**
* Sets the additional tileset loaders that should be attempted before falling back to the default
* tileset loader the renderer uses.
*
* **Order matters.** Loaders earlier in the list will be attempted first.
*/
fun withTilesetLoaders(vararg loaders: TilesetLoader<*>) = also {
config = config.copy(
tilesetLoaders = loaders.toList()
)
}

@Deprecated("This will be removed in the next version, as the behavior is inconsistent.")
fun withFullScreen(screenWidth: Int, screenHeight: Int) = also {
throw UnsupportedOperationException("Unstable api, use withFullScreen(true) instead")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ interface TilesetResource : Identifiable {

val tileType: TileType
val tilesetType: TilesetType
/**
* A "stringly-typed" field to identify tilesets to be loaded by custom tileset loaders.
*
* For example, a tileset loader designed for Tiled might use a subtype of `TILED`.
*/
val tilesetSubtype: String? get() = null
val tilesetSourceType: TilesetSourceType
val width: Int
val height: Int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.hexworks.zircon.api.tileset

import org.hexworks.zircon.api.resource.TilesetResource

/**
* A higher-order [TilesetLoader] that will try to load a resource using [loaderA], and if it can't, tries [loaderB].
* If neither can load the resource, an exception is thrown.
*
* Whether a [TilesetLoader] is able to load a [TilesetResource] is dependent on [TilesetLoader.canLoadResource].
*/
class ChainedTilesetLoader<T : Any>(
private val loaderA: TilesetLoader<T>,
private val loaderB: TilesetLoader<T>
) : TilesetLoader<T> {
override fun loadTilesetFrom(resource: TilesetResource): Tileset<T> =
when {
loaderA.canLoadResource(resource) -> loaderA.loadTilesetFrom(resource)
loaderB.canLoadResource(resource) -> loaderB.loadTilesetFrom(resource)
else -> throw IllegalArgumentException("Unknown tile type '${resource.tileType}'.")
}

override fun canLoadResource(resource: TilesetResource): Boolean =
loaderA.canLoadResource(resource) || loaderB.canLoadResource(resource)

companion object {
/**
* Constructs a new [TilesetLoader] that will check each [TilesetLoader] given, in order, until it finds one
* that can load the given resource.
*/
fun <T : Any> inOrder(tilesetLoaders: List<TilesetLoader<T>>): TilesetLoader<T> {
return when (tilesetLoaders.size) {
0 -> throw IllegalArgumentException("tilesetLoaders cannot be empty")
1 -> tilesetLoaders.first()
else -> tilesetLoaders.reduceRight { right: TilesetLoader<T>, acc: TilesetLoader<T> -> ChainedTilesetLoader(right, acc) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ interface TilesetLoader<T : Any> {
*/
fun loadTilesetFrom(resource: TilesetResource): Tileset<T>

/**
* Returns true if calling [loadTilesetFrom] for this particular [resource] will likely succeed.
*/
fun canLoadResource(resource: TilesetResource): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package org.hexworks.zircon.api.tileset

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.hexworks.zircon.api.resource.TilesetResource
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule
import org.mockito.kotlin.*
import org.mockito.quality.Strictness

/*
This is a very rigid set of test cases. Normally this is an anti-pattern, but we want to verify very specific behavior
here. Exactly these methods are called in exactly this particular order.
*/
class ChainedTilesetLoaderTest {
@get:Rule
val mockitoRule: MockitoRule = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS)

@Mock(name="loaderA")
lateinit var loaderA: TilesetLoader<Any>
@Mock(name="loaderB")
lateinit var loaderB: TilesetLoader<Any>
@Mock(name="loaderC")
lateinit var loaderC: TilesetLoader<Any>

@Test
fun firstLoaderCanLoad() {
val mockTilesetResource = mock<TilesetResource>()
val mockTileset = mock<Tileset<Any>>()
whenever(loaderA.canLoadResource(any())).thenReturn(true)
whenever(loaderA.loadTilesetFrom(any())).thenReturn(mockTileset)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThat(chained.loadTilesetFrom(mockTilesetResource)).isSameAs(mockTileset)
}

verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderA).loadTilesetFrom(mockTilesetResource)
}

@Test
fun secondLoaderCanLoad() {
val mockTilesetResource = mock<TilesetResource>()
val mockTileset = mock<Tileset<Any>>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(true)
whenever(loaderB.loadTilesetFrom(any())).thenReturn(mockTileset)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThat(chained.loadTilesetFrom(mockTilesetResource)).isSameAs(mockTileset)
}

inOrder(loaderA, loaderB) {
verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
verify(loaderB).loadTilesetFrom(mockTilesetResource)
}
}

@Test
fun neitherCanLoad() {
val mockTilesetResource = mock<TilesetResource>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(false)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThatThrownBy {
chained.loadTilesetFrom(mockTilesetResource)
}
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("Unknown tile type")
}

verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
}

@Test
fun canLoadResourceA() {
val mockTilesetResource = mock<TilesetResource>()
whenever(loaderA.canLoadResource(any())).thenReturn(true)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThat(chained.canLoadResource(mockTilesetResource)).isTrue()
}

verify(loaderA).canLoadResource(mockTilesetResource)
}

@Test
fun canLoadResourceB() {
val mockTilesetResource = mock<TilesetResource>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(true)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThat(chained.canLoadResource(mockTilesetResource)).isTrue()
}

inOrder(loaderA, loaderB) {
verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
}
}

@Test
fun canLoadResourceC() {
val mockTilesetResource = mock<TilesetResource>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(false)

ChainedTilesetLoader(loaderA, loaderB).also { chained ->
assertThat(chained.canLoadResource(mockTilesetResource)).isFalse()
}

verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
}

@Test
fun inOrder_empty() {
assertThatThrownBy {
ChainedTilesetLoader.inOrder(emptyList<TilesetLoader<Any>>())
}
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("cannot be empty")
}

@Test
fun inOrder_single() {
// This is a little odd, but really why have chaining at all if you only have a single loader anyway?
assertThat(ChainedTilesetLoader.inOrder(listOf(loaderA)))
.isSameAs(loaderA)
}

@Test
fun inOrder_double() {
val chained = ChainedTilesetLoader.inOrder(listOf(loaderA, loaderB))

val mockTilesetResource = mock<TilesetResource>()
val mockTileset = mock<Tileset<Any>>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(true)
whenever(loaderB.loadTilesetFrom(any())).thenReturn(mockTileset)

assertThat(chained.loadTilesetFrom(mockTilesetResource)).isSameAs(mockTileset)

inOrder(loaderA, loaderB) {
verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
verify(loaderB).loadTilesetFrom(mockTilesetResource)
}
}

@Test
fun inOrder_triple() {
val chained = ChainedTilesetLoader.inOrder(listOf(loaderA, loaderB, loaderC))

val mockTilesetResource = mock<TilesetResource>()
val mockTileset = mock<Tileset<Any>>()
whenever(loaderA.canLoadResource(any())).thenReturn(false)
whenever(loaderB.canLoadResource(any())).thenReturn(false)
whenever(loaderC.canLoadResource(any())).thenReturn(true)
whenever(loaderC.loadTilesetFrom(any())).thenReturn(mockTileset)

assertThat(chained.loadTilesetFrom(mockTilesetResource)).isSameAs(mockTileset)

inOrder(loaderA, loaderB, loaderC) {
verify(loaderA).canLoadResource(mockTilesetResource)
verify(loaderB).canLoadResource(mockTilesetResource)
verify(loaderC).canLoadResource(mockTilesetResource)
verify(loaderC).loadTilesetFrom(mockTilesetResource)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import org.hexworks.zircon.api.data.Position
import org.hexworks.zircon.api.data.Tile
import org.hexworks.zircon.api.modifier.TileTransformModifier
import org.hexworks.zircon.api.resource.TilesetResource
import org.hexworks.zircon.api.tileset.ChainedTilesetLoader
import org.hexworks.zircon.api.tileset.Tileset
import org.hexworks.zircon.api.tileset.TilesetLoader
import org.hexworks.zircon.internal.RunTimeStats
import org.hexworks.zircon.internal.config.RuntimeConfig
import org.hexworks.zircon.internal.data.PixelPosition
Expand All @@ -39,7 +41,9 @@ class LibgdxRenderer(
private val config = RuntimeConfig.config
private var maybeBatch: Maybe<SpriteBatch> = Maybes.empty()
private lateinit var cursorRenderer: ShapeRenderer
private val tilesetLoader = LibgdxTilesetLoader()
private val tilesetLoader: TilesetLoader<SpriteBatch> =
ChainedTilesetLoader.inOrder(config.tilesetLoaders as List<TilesetLoader<SpriteBatch>>
+ LibgdxTilesetLoader())
private var blinkOn = true
private var timeSinceLastBlink: Float = 0f

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ class LibgdxTilesetLoader : TilesetLoader<SpriteBatch>, Closeable {
override fun loadTilesetFrom(resource: TilesetResource): Tileset<SpriteBatch> {
return tilesetCache.getOrPut(resource.id) {
LOADERS[resource.getLoaderKey()]?.invoke(resource)
?: throw IllegalArgumentException("Unknown tile type '${resource.tileType}'")
?: throw IllegalArgumentException("Unknown tile type '${resource.tileType}', can't use ${resource.getLoaderKey()}.")
}
}

override fun canLoadResource(resource: TilesetResource): Boolean =
resource.id in tilesetCache || resource.getLoaderKey() in LOADERS

override fun close() {
isClosed.value = true
tilesetCache.clear()
Expand All @@ -48,7 +51,6 @@ class LibgdxTilesetLoader : TilesetLoader<SpriteBatch>, Closeable {
LibgdxMonospaceFontTileset(
resource = resource)
}
//TODO Support for other types of tilesets
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import org.hexworks.zircon.api.data.Tile
import org.hexworks.zircon.api.graphics.TileGraphics
import org.hexworks.zircon.api.modifier.TileTransformModifier
import org.hexworks.zircon.api.resource.TilesetResource
import org.hexworks.zircon.api.tileset.ChainedTilesetLoader
import org.hexworks.zircon.api.tileset.Tileset
import org.hexworks.zircon.api.tileset.TilesetLoader
import org.hexworks.zircon.internal.graphics.FastTileGraphics
import org.hexworks.zircon.internal.grid.InternalTileGrid
import org.hexworks.zircon.internal.tileset.SwingTilesetLoader
Expand Down Expand Up @@ -44,7 +46,9 @@ class SwingCanvasRenderer(
private var lastRender: Long = SystemUtils.getCurrentTimeMs()
private var lastBlink: Long = lastRender

private val tilesetLoader = SwingTilesetLoader()
private val tilesetLoader: TilesetLoader<Graphics2D> =
ChainedTilesetLoader.inOrder(config.tilesetLoaders as List<TilesetLoader<Graphics2D>>
+ SwingTilesetLoader())
private val keyboardEventListener = KeyboardEventListener()
private val mouseEventListener = object : MouseEventListener(
fontWidth = tileGrid.tileset.width,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,19 @@ class SwingTilesetLoader : TilesetLoader<Graphics2D>, Closeable {
override fun loadTilesetFrom(resource: TilesetResource): Tileset<Graphics2D> {
return tilesetCache.getOrPut(resource.id) {
LOADERS[resource.getLoaderKey()]?.invoke(resource)
?: throw IllegalArgumentException("Unknown tile type '${resource.tileType}'")
?: throw IllegalArgumentException("Unknown tile type '${resource.tileType}', can't use ${resource.getLoaderKey()}.")
}
}

override fun canLoadResource(resource: TilesetResource): Boolean =
resource.id in tilesetCache || resource.getLoaderKey() in LOADERS

override fun close() {
isClosed.value = true
tilesetCache.clear()
}

companion object {

fun TilesetResource.getLoaderKey() = "${this.tileType.name}-${this.tilesetType.name}"

private val LOADERS: Map<String, (TilesetResource) -> Tileset<Graphics2D>> = mapOf(
Expand Down

0 comments on commit cccbc22

Please sign in to comment.