Quest NPCs are now (poorly) shown in the 3D view.

This commit is contained in:
Daan Vanden Bosch 2020-11-02 20:04:44 +01:00
parent c028c09ac9
commit 17ef42fba7
68 changed files with 5038 additions and 376 deletions

View File

@ -10,46 +10,26 @@ import world.phantasmal.lib.fileFormats.vec3F32
private const val NJCM: Int = 0x4D434A4E
class NjObject<Model>(
val evaluationFlags: NjEvaluationFlags,
val model: Model?,
val position: Vec3,
/**
* Euler angles in radians.
*/
val rotation: Vec3,
val scale: Vec3,
val children: List<NjObject<Model>>,
)
class NjEvaluationFlags(
val noTranslate: Boolean,
val noRotate: Boolean,
val noScale: Boolean,
val hidden: Boolean,
val breakChildTrace: Boolean,
val zxyRotationOrder: Boolean,
val skip: Boolean,
val shapeSkip: Boolean,
)
fun parseNj(cursor: Cursor): PwResult<List<NjObject<NjcmModel>>> =
fun parseNj(cursor: Cursor): PwResult<List<NinjaObject<NjcmModel>>> =
parseNinja(cursor, ::parseNjcmModel, mutableMapOf())
private fun <Model, Context> parseNinja(
fun parseXj(cursor: Cursor): PwResult<List<NinjaObject<XjModel>>> =
parseNinja(cursor, { _, _ -> XjModel() }, Unit)
private fun <Model : NinjaModel, Context> parseNinja(
cursor: Cursor,
parse_model: (cursor: Cursor, context: Context) -> Model,
parseModel: (cursor: Cursor, context: Context) -> Model,
context: Context,
): PwResult<List<NjObject<Model>>> =
): PwResult<List<NinjaObject<Model>>> =
when (val parseIffResult = parseIff(cursor)) {
is Failure -> parseIffResult
is Success -> {
// POF0 and other chunks types are ignored.
val njcmChunks = parseIffResult.value.filter { chunk -> chunk.type == NJCM }
val objects: MutableList<NjObject<Model>> = mutableListOf()
val objects: MutableList<NinjaObject<Model>> = mutableListOf()
for (chunk in njcmChunks) {
objects.addAll(parseSiblingObjects(chunk.data, parse_model, context))
objects.addAll(parseSiblingObjects(chunk.data, parseModel, context))
}
Success(objects, parseIffResult.problems)
@ -57,11 +37,11 @@ private fun <Model, Context> parseNinja(
}
// TODO: cache model and object offsets so we don't reparse the same data.
private fun <Model, Context> parseSiblingObjects(
private fun <Model : NinjaModel, Context> parseSiblingObjects(
cursor: Cursor,
parse_model: (cursor: Cursor, context: Context) -> Model,
parseModel: (cursor: Cursor, context: Context) -> Model,
context: Context,
): List<NjObject<Model>> {
): MutableList<NinjaObject<Model>> {
val evalFlags = cursor.uInt()
val noTranslate = (evalFlags and 0b1u) != 0u
val noRotate = (evalFlags and 0b10u) != 0u
@ -87,25 +67,25 @@ private fun <Model, Context> parseSiblingObjects(
null
} else {
cursor.seekStart(modelOffset)
parse_model(cursor, context)
parseModel(cursor, context)
}
val children = if (childOffset == 0) {
emptyList()
mutableListOf()
} else {
cursor.seekStart(childOffset)
parseSiblingObjects(cursor, parse_model, context)
parseSiblingObjects(cursor, parseModel, context)
}
val siblings = if (siblingOffset == 0) {
emptyList()
mutableListOf()
} else {
cursor.seekStart(siblingOffset)
parseSiblingObjects(cursor, parse_model, context)
parseSiblingObjects(cursor, parseModel, context)
}
val obj = NjObject(
NjEvaluationFlags(
val obj = NinjaObject(
NinjaEvaluationFlags(
noTranslate,
noRotate,
noScale,
@ -122,5 +102,6 @@ private fun <Model, Context> parseSiblingObjects(
children,
)
return listOf(obj) + siblings
siblings.add(0, obj)
return siblings
}

View File

@ -0,0 +1,152 @@
package world.phantasmal.lib.fileFormats.ninja
import world.phantasmal.lib.fileFormats.Vec2
import world.phantasmal.lib.fileFormats.Vec3
class NinjaObject<Model : NinjaModel>(
val evaluationFlags: NinjaEvaluationFlags,
val model: Model?,
val position: Vec3,
/**
* Euler angles in radians.
*/
val rotation: Vec3,
val scale: Vec3,
children: MutableList<NinjaObject<Model>>,
) {
private val _children = children
val children: List<NinjaObject<Model>> = _children
fun addChild(child: NinjaObject<Model>) {
_children.add(child)
}
}
class NinjaEvaluationFlags(
var noTranslate: Boolean,
var noRotate: Boolean,
var noScale: Boolean,
var hidden: Boolean,
var breakChildTrace: Boolean,
var zxyRotationOrder: Boolean,
var skip: Boolean,
var shapeSkip: Boolean,
)
sealed class NinjaModel
/**
* The model type used in .nj files.
*/
class NjcmModel(
/**
* Sparse list of vertices.
*/
val vertices: List<NjcmVertex?>,
val meshes: List<NjcmTriangleStrip>,
val collisionSphereCenter: Vec3,
val collisionSphereRadius: Float,
) : NinjaModel()
class NjcmVertex(
val position: Vec3,
val normal: Vec3?,
val boneWeight: Float,
val boneWeightStatus: Int,
val calcContinue: Boolean,
)
class NjcmTriangleStrip(
val ignoreLight: Boolean,
val ignoreSpecular: Boolean,
val ignoreAmbient: Boolean,
val useAlpha: Boolean,
val doubleSide: Boolean,
val flatShading: Boolean,
val environmentMapping: Boolean,
val clockwiseWinding: Boolean,
val hasTexCoords: Boolean,
val hasNormal: Boolean,
var textureId: UInt?,
var srcAlpha: UByte?,
var dstAlpha: UByte?,
val vertices: List<NjcmMeshVertex>,
)
class NjcmMeshVertex(
val index: UShort,
val normal: Vec3?,
val texCoords: Vec2?,
)
sealed class NjcmChunk(val typeId: UByte) {
class Unknown(typeId: UByte) : NjcmChunk(typeId)
object Null : NjcmChunk(0u)
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjcmChunk(typeId)
class CachePolygonList(val cacheIndex: UByte, val offset: Int) : NjcmChunk(4u)
class DrawPolygonList(val cacheIndex: UByte) : NjcmChunk(5u)
class Tiny(
typeId: UByte,
val flipU: Boolean,
val flipV: Boolean,
val clampU: Boolean,
val clampV: Boolean,
val mipmapDAdjust: UInt,
val filterMode: UInt,
val superSample: Boolean,
val textureId: UInt,
) : NjcmChunk(typeId)
class Material(
typeId: UByte,
val srcAlpha: UByte,
val dstAlpha: UByte,
val diffuse: NjcmArgb?,
val ambient: NjcmArgb?,
val specular: NjcmErgb?,
) : NjcmChunk(typeId)
class Vertex(typeId: UByte, val vertices: List<NjcmChunkVertex>) : NjcmChunk(typeId)
class Volume(typeId: UByte) : NjcmChunk(typeId)
class Strip(typeId: UByte, val triangleStrips: List<NjcmTriangleStrip>) : NjcmChunk(typeId)
object End : NjcmChunk(255u)
}
class NjcmChunkVertex(
val index: Int,
val position: Vec3,
val normal: Vec3?,
val boneWeight: Float,
val boneWeightStatus: Int,
val calcContinue: Boolean,
)
/**
* Channels are in range [0, 1].
*/
class NjcmArgb(
val a: Float,
val r: Float,
val g: Float,
val b: Float,
)
class NjcmErgb(
val e: UByte,
val r: UByte,
val g: UByte,
val b: UByte,
)
/**
* The model type used in .xj files.
*/
class XjModel : NinjaModel()

View File

@ -14,120 +14,12 @@ import kotlin.math.abs
private val logger = KotlinLogging.logger {}
class NjcmModel(
/**
* Sparse list of vertices.
*/
val vertices: List<NjcmVertex>,
val meshes: List<NjcmTriangleStrip>,
val collisionSphereCenter: Vec3,
val collisionSphereRadius: Float,
)
class NjcmVertex(
val position: Vec3,
val normal: Vec3?,
val boneWeight: Float,
val boneWeightStatus: UByte,
val calcContinue: Boolean,
)
class NjcmTriangleStrip(
val ignoreLight: Boolean,
val ignoreSpecular: Boolean,
val ignoreAmbient: Boolean,
val useAlpha: Boolean,
val doubleSide: Boolean,
val flatShading: Boolean,
val environmentMapping: Boolean,
val clockwiseWinding: Boolean,
val hasTexCoords: Boolean,
val hasNormal: Boolean,
var textureId: UInt?,
var srcAlpha: UByte?,
var dstAlpha: UByte?,
val vertices: List<NjcmMeshVertex>,
)
class NjcmMeshVertex(
val index: UShort,
val normal: Vec3?,
val texCoords: Vec2?,
)
sealed class NjcmChunk(val typeId: UByte) {
class Unknown(typeId: UByte) : NjcmChunk(typeId)
object Null : NjcmChunk(0u)
class Bits(typeId: UByte, val srcAlpha: UByte, val dstAlpha: UByte) : NjcmChunk(typeId)
class CachePolygonList(val cacheIndex: UByte, val offset: Int) : NjcmChunk(4u)
class DrawPolygonList(val cacheIndex: UByte) : NjcmChunk(5u)
class Tiny(
typeId: UByte,
val flipU: Boolean,
val flipV: Boolean,
val clampU: Boolean,
val clampV: Boolean,
val mipmapDAdjust: UInt,
val filterMode: UInt,
val superSample: Boolean,
val textureId: UInt,
) : NjcmChunk(typeId)
class Material(
typeId: UByte,
val srcAlpha: UByte,
val dstAlpha: UByte,
val diffuse: NjcmArgb?,
val ambient: NjcmArgb?,
val specular: NjcmErgb?,
) : NjcmChunk(typeId)
class Vertex(typeId: UByte, val vertices: List<NjcmChunkVertex>) : NjcmChunk(typeId)
class Volume(typeId: UByte) : NjcmChunk(typeId)
class Strip(typeId: UByte, val triangleStrips: List<NjcmTriangleStrip>) : NjcmChunk(typeId)
object End : NjcmChunk(255u)
}
class NjcmChunkVertex(
val index: Int,
val position: Vec3,
val normal: Vec3?,
val boneWeight: Float,
val boneWeightStatus: UByte,
val calcContinue: Boolean,
)
/**
* Channels are in range [0, 1].
*/
class NjcmArgb(
val a: Float,
val r: Float,
val g: Float,
val b: Float,
)
class NjcmErgb(
val e: UByte,
val r: UByte,
val g: UByte,
val b: UByte,
)
fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>): NjcmModel {
val vlistOffset = cursor.int() // Vertex list
val plistOffset = cursor.int() // Triangle strip index list
val boundingSphereCenter = cursor.vec3F32()
val boundingSphereRadius = cursor.float()
val vertices: MutableList<NjcmVertex> = mutableListOf()
val vertices: MutableList<NjcmVertex?> = mutableListOf()
val meshes: MutableList<NjcmTriangleStrip> = mutableListOf()
if (vlistOffset != 0) {
@ -136,6 +28,10 @@ fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>):
for (chunk in parseChunks(cursor, cachedChunkOffsets, true)) {
if (chunk is NjcmChunk.Vertex) {
for (vertex in chunk.vertices) {
while (vertices.size <= vertex.index) {
vertices.add(null)
}
vertices[vertex.index] = NjcmVertex(
vertex.position,
vertex.normal,
@ -156,23 +52,19 @@ fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>):
var dstAlpha: UByte? = null
for (chunk in parseChunks(cursor, cachedChunkOffsets, false)) {
@Suppress("UNUSED_VALUE") // Ignore useless warning due to compiler bug.
when (chunk) {
is NjcmChunk.Bits -> {
srcAlpha = chunk.srcAlpha
dstAlpha = chunk.dstAlpha
break
}
is NjcmChunk.Tiny -> {
textureId = chunk.textureId
break
}
is NjcmChunk.Material -> {
srcAlpha = chunk.srcAlpha
dstAlpha = chunk.dstAlpha
break
}
is NjcmChunk.Strip -> {
@ -183,7 +75,6 @@ fun parseNjcmModel(cursor: Cursor, cachedChunkOffsets: MutableMap<UByte, Int>):
}
meshes.addAll(chunk.triangleStrips)
break
}
else -> {
@ -357,7 +248,7 @@ private fun parseVertexChunk(
chunkTypeId: UByte,
flags: UByte,
): List<NjcmChunkVertex> {
val boneWeightStatus = flags and 0b11u
val boneWeightStatus = (flags and 0b11u).toInt()
val calcContinue = (flags and 0x80u) != ZERO_U8
val index = cursor.uShort()
@ -451,9 +342,9 @@ private fun parseTriangleStripChunk(
val flatShading = (flags and 0b100000u) != ZERO_U8
val environmentMapping = (flags and 0b1000000u) != ZERO_U8
val userOffsetAndStripCount = cursor.uShort()
val userFlagsSize = (userOffsetAndStripCount.toUInt() shr 14).toInt()
val stripCount = userOffsetAndStripCount and 0x3fffu
val userOffsetAndStripCount = cursor.short().toInt()
val userFlagsSize = (userOffsetAndStripCount ushr 14)
val stripCount = userOffsetAndStripCount and 0x3FFF
var hasTexCoords = false
var hasColor = false
@ -490,14 +381,14 @@ private fun parseTriangleStripChunk(
val strips: MutableList<NjcmTriangleStrip> = mutableListOf()
repeat(stripCount.toInt()) {
repeat(stripCount) {
val windingFlagAndIndexCount = cursor.short()
val clockwiseWinding = windingFlagAndIndexCount < 1
val indexCount = abs(windingFlagAndIndexCount.toInt())
val vertices: MutableList<NjcmMeshVertex> = mutableListOf()
for (j in 0..indexCount) {
for (j in 0 until indexCount) {
val index = cursor.uShort()
val texCoords = if (hasTexCoords) {

View File

@ -0,0 +1,163 @@
package world.phantasmal.lib.fileFormats.quest
// TODO: detect Mothmant, St. Rappy, Hallo Rappy, Egg Rappy, Death Gunner, Bulk and Recon.
fun npcTypeFromQuestNpc(npc: QuestNpc): NpcType {
val episode = npc.episode
val special = npc.special
val skin = npc.skin
val areaId = npc.areaId
return when (npc.typeId.toInt()) {
0x004 -> NpcType.FemaleFat
0x005 -> NpcType.FemaleMacho
0x007 -> NpcType.FemaleTall
0x00A -> NpcType.MaleDwarf
0x00B -> NpcType.MaleFat
0x00C -> NpcType.MaleMacho
0x00D -> NpcType.MaleOld
0x019 -> NpcType.BlueSoldier
0x01A -> NpcType.RedSoldier
0x01B -> NpcType.Principal
0x01C -> NpcType.Tekker
0x01D -> NpcType.GuildLady
0x01E -> NpcType.Scientist
0x01F -> NpcType.Nurse
0x020 -> NpcType.Irene
0x040 -> when (skin % 2) {
0 -> if (episode == Episode.II) NpcType.Hildebear2 else NpcType.Hildebear
else -> if (episode == Episode.II) NpcType.Hildeblue2 else NpcType.Hildeblue
}
0x041 -> when (skin % 2) {
0 -> when (episode) {
Episode.I -> NpcType.RagRappy
Episode.II -> NpcType.RagRappy2
Episode.IV -> NpcType.SandRappy
}
else -> when (episode) {
Episode.I -> NpcType.AlRappy
Episode.II -> NpcType.LoveRappy
Episode.IV -> NpcType.DelRappy
}
}
0x042 -> if (episode == Episode.II) NpcType.Monest2 else NpcType.Monest
0x043 -> when (special) {
true -> if (episode == Episode.II) NpcType.BarbarousWolf2 else NpcType.BarbarousWolf
false -> if (episode == Episode.II) NpcType.SavageWolf2 else NpcType.SavageWolf
}
0x044 -> when (skin % 3) {
0 -> NpcType.Booma
1 -> NpcType.Gobooma
else -> NpcType.Gigobooma
}
0x060 -> if (episode == Episode.II) NpcType.GrassAssassin2 else NpcType.GrassAssassin
0x061 -> when {
areaId > 15 -> NpcType.DelLily
special -> if (episode == Episode.II) NpcType.NarLily2 else NpcType.NarLily
else -> if (episode == Episode.II) NpcType.PoisonLily2 else NpcType.PoisonLily
}
0x062 -> NpcType.NanoDragon
0x063 -> when (skin % 3) {
0 -> NpcType.EvilShark
1 -> NpcType.PalShark
else -> NpcType.GuilShark
}
0x064 -> if (special) NpcType.PouillySlime else NpcType.PofuillySlime
0x065 -> if (episode == Episode.II) NpcType.PanArms2 else NpcType.PanArms
0x080 -> when (skin % 2) {
0 -> if (episode == Episode.II) NpcType.Dubchic2 else NpcType.Dubchic
else -> if (episode == Episode.II) NpcType.Gilchic2 else NpcType.Gilchic
}
0x081 -> if (episode == Episode.II) NpcType.Garanz2 else NpcType.Garanz
0x082 -> if (special) NpcType.SinowGold else NpcType.SinowBeat
0x083 -> NpcType.Canadine
0x084 -> NpcType.Canane
0x085 -> if (episode == Episode.II) NpcType.Dubswitch2 else NpcType.Dubswitch
0x0A0 -> if (episode == Episode.II) NpcType.Delsaber2 else NpcType.Delsaber
0x0A1 -> if (episode == Episode.II) NpcType.ChaosSorcerer2 else NpcType.ChaosSorcerer
0x0A2 -> NpcType.DarkGunner
0x0A4 -> NpcType.ChaosBringer
0x0A5 -> if (episode == Episode.II) NpcType.DarkBelra2 else NpcType.DarkBelra
0x0A6 -> when (skin % 3) {
0 -> if (episode == Episode.II) NpcType.Dimenian2 else NpcType.Dimenian
1 -> if (episode == Episode.II) NpcType.LaDimenian2 else NpcType.LaDimenian
else -> if (episode == Episode.II) NpcType.SoDimenian2 else NpcType.SoDimenian
}
0x0A7 -> NpcType.Bulclaw
0x0A8 -> NpcType.Claw
0x0C0 -> if (episode == Episode.II) NpcType.GalGryphon else NpcType.Dragon
0x0C1 -> NpcType.DeRolLe
0x0C2 -> NpcType.VolOptPart1
0x0C5 -> NpcType.VolOptPart2
0x0C8 -> NpcType.DarkFalz
0x0CA -> NpcType.OlgaFlow
0x0CB -> NpcType.BarbaRay
0x0CC -> NpcType.GolDragon
0x0D4 -> when (skin % 2) {
0 -> NpcType.SinowBerill
else -> NpcType.SinowSpigell
}
0x0D5 -> when (skin % 2) {
0 -> NpcType.Merillia
else -> NpcType.Meriltas
}
0x0D6 -> when (skin % 3) {
0 -> NpcType.Mericarol
1 -> NpcType.Mericus
else -> NpcType.Merikle
}
0x0D7 -> when (skin % 2) {
0 -> NpcType.UlGibbon
else -> NpcType.ZolGibbon
}
0x0D8 -> NpcType.Gibbles
0x0D9 -> NpcType.Gee
0x0DA -> NpcType.GiGue
0x0DB -> NpcType.Deldepth
0x0DC -> NpcType.Delbiter
0x0DD -> when (skin % 2) {
0 -> NpcType.Dolmolm
else -> NpcType.Dolmdarl
}
0x0DE -> NpcType.Morfos
0x0DF -> NpcType.Recobox
0x0E0 -> when {
areaId > 15 -> NpcType.Epsilon
skin % 2 == 0 -> NpcType.SinowZoa
else -> NpcType.SinowZele
}
0x0E1 -> NpcType.IllGill
0x0F1 -> NpcType.ItemShop
0x0FE -> NpcType.Nurse2
0x110 -> NpcType.Astark
0x111 -> if (special) NpcType.Yowie else NpcType.SatelliteLizard
0x112 -> when (skin % 2) {
0 -> NpcType.MerissaA
else -> NpcType.MerissaAA
}
0x113 -> NpcType.Girtablulu
0x114 -> when (skin % 2) {
0 -> NpcType.Zu
else -> NpcType.Pazuzu
}
0x115 -> when (skin % 3) {
0 -> NpcType.Boota
1 -> NpcType.ZeBoota
else -> NpcType.BaBoota
}
0x116 -> when (skin % 2) {
0 -> NpcType.Dorphon
else -> NpcType.DorphonEclair
}
0x117 -> when (skin % 3) {
0 -> NpcType.Goran
1 -> NpcType.PyroGoran
else -> NpcType.GoranDetonator
}
0x119 -> when {
special -> NpcType.Kondrieu
skin % 2 == 0 -> NpcType.SaintMilion
else -> NpcType.Shambertin
}
else -> NpcType.Unknown
}
}

View File

@ -0,0 +1,285 @@
package world.phantasmal.lib.fileFormats.quest
fun objectTypeFromId(id: Int): ObjectType =
when (id) {
0 -> ObjectType.PlayerSet
1 -> ObjectType.Particle
2 -> ObjectType.Teleporter
3 -> ObjectType.Warp
4 -> ObjectType.LightCollision
5 -> ObjectType.Item
6 -> ObjectType.EnvSound
7 -> ObjectType.FogCollision
8 -> ObjectType.EventCollision
9 -> ObjectType.CharaCollision
10 -> ObjectType.ElementalTrap
11 -> ObjectType.StatusTrap
12 -> ObjectType.HealTrap
13 -> ObjectType.LargeElementalTrap
14 -> ObjectType.ObjRoomID
15 -> ObjectType.Sensor
16 -> ObjectType.UnknownItem16
17 -> ObjectType.LensFlare
18 -> ObjectType.ScriptCollision
19 -> ObjectType.HealRing
20 -> ObjectType.MapCollision
21 -> ObjectType.ScriptCollisionA
22 -> ObjectType.ItemLight
23 -> ObjectType.RadarCollision
24 -> ObjectType.FogCollisionSW
25 -> ObjectType.BossTeleporter
26 -> ObjectType.ImageBoard
27 -> ObjectType.QuestWarp
28 -> ObjectType.Epilogue
29 -> ObjectType.UnknownItem29
30 -> ObjectType.UnknownItem30
31 -> ObjectType.UnknownItem31
32 -> ObjectType.BoxDetectObject
33 -> ObjectType.SymbolChatObject
34 -> ObjectType.TouchPlateObject
35 -> ObjectType.TargetableObject
36 -> ObjectType.EffectObject
37 -> ObjectType.CountDownObject
38 -> ObjectType.UnknownItem38
39 -> ObjectType.UnknownItem39
40 -> ObjectType.UnknownItem40
41 -> ObjectType.UnknownItem41
64 -> ObjectType.MenuActivation
65 -> ObjectType.TelepipeLocation
66 -> ObjectType.BGMCollision
67 -> ObjectType.MainRagolTeleporter
68 -> ObjectType.LobbyTeleporter
69 -> ObjectType.PrincipalWarp
70 -> ObjectType.ShopDoor
71 -> ObjectType.HuntersGuildDoor
72 -> ObjectType.TeleporterDoor
73 -> ObjectType.MedicalCenterDoor
74 -> ObjectType.Elevator
75 -> ObjectType.EasterEgg
76 -> ObjectType.ValentinesHeart
77 -> ObjectType.ChristmasTree
78 -> ObjectType.ChristmasWreath
79 -> ObjectType.HalloweenPumpkin
80 -> ObjectType.TwentyFirstCentury
81 -> ObjectType.Sonic
82 -> ObjectType.WelcomeBoard
83 -> ObjectType.Firework
84 -> ObjectType.LobbyScreenDoor
85 -> ObjectType.MainRagolTeleporterBattleInNextArea
86 -> ObjectType.LabTeleporterDoor
87 -> ObjectType.Pioneer2InvisibleTouchplate
128 -> ObjectType.ForestDoor
129 -> ObjectType.ForestSwitch
130 -> ObjectType.LaserFence
131 -> ObjectType.LaserSquareFence
132 -> ObjectType.ForestLaserFenceSwitch
133 -> ObjectType.LightRays
134 -> ObjectType.BlueButterfly
135 -> ObjectType.Probe
136 -> ObjectType.RandomTypeBox1
137 -> ObjectType.ForestWeatherStation
138 -> ObjectType.Battery
139 -> ObjectType.ForestConsole
140 -> ObjectType.BlackSlidingDoor
141 -> ObjectType.RicoMessagePod
142 -> ObjectType.EnergyBarrier
143 -> ObjectType.ForestRisingBridge
144 -> ObjectType.SwitchNoneDoor
145 -> ObjectType.EnemyBoxGrey
146 -> ObjectType.FixedTypeBox
147 -> ObjectType.EnemyBoxBrown
149 -> ObjectType.EmptyTypeBox
150 -> ObjectType.LaserFenceEx
151 -> ObjectType.LaserSquareFenceEx
192 -> ObjectType.FloorPanel1
193 -> ObjectType.Caves4ButtonDoor
194 -> ObjectType.CavesNormalDoor
195 -> ObjectType.CavesSmashingPillar
196 -> ObjectType.CavesSign1
197 -> ObjectType.CavesSign2
198 -> ObjectType.CavesSign3
199 -> ObjectType.HexagonalTank
200 -> ObjectType.BrownPlatform
201 -> ObjectType.WarningLightObject
203 -> ObjectType.Rainbow
204 -> ObjectType.FloatingJellyfish
205 -> ObjectType.FloatingDragonfly
206 -> ObjectType.CavesSwitchDoor
207 -> ObjectType.RobotRechargeStation
208 -> ObjectType.CavesCakeShop
209 -> ObjectType.Caves1SmallRedRock
210 -> ObjectType.Caves1MediumRedRock
211 -> ObjectType.Caves1LargeRedRock
212 -> ObjectType.Caves2SmallRock1
213 -> ObjectType.Caves2MediumRock1
214 -> ObjectType.Caves2LargeRock1
215 -> ObjectType.Caves2SmallRock2
216 -> ObjectType.Caves2MediumRock2
217 -> ObjectType.Caves2LargeRock2
218 -> ObjectType.Caves3SmallRock
219 -> ObjectType.Caves3MediumRock
220 -> ObjectType.Caves3LargeRock
222 -> ObjectType.FloorPanel2
223 -> ObjectType.DestructableRockCaves1
224 -> ObjectType.DestructableRockCaves2
225 -> ObjectType.DestructableRockCaves3
256 -> ObjectType.MinesDoor
257 -> ObjectType.FloorPanel3
258 -> ObjectType.MinesSwitchDoor
259 -> ObjectType.LargeCryoTube
260 -> ObjectType.ComputerLikeCalus
261 -> ObjectType.GreenScreenOpeningAndClosing
262 -> ObjectType.FloatingRobot
263 -> ObjectType.FloatingBlueLight
264 -> ObjectType.SelfDestructingObject1
265 -> ObjectType.SelfDestructingObject2
266 -> ObjectType.SelfDestructingObject3
267 -> ObjectType.SparkMachine
268 -> ObjectType.MinesLargeFlashingCrate
304 -> ObjectType.RuinsSeal
320 -> ObjectType.RuinsTeleporter
321 -> ObjectType.RuinsWarpSiteToSite
322 -> ObjectType.RuinsSwitch
323 -> ObjectType.FloorPanel4
324 -> ObjectType.Ruins1Door
325 -> ObjectType.Ruins3Door
326 -> ObjectType.Ruins2Door
327 -> ObjectType.Ruins11ButtonDoor
328 -> ObjectType.Ruins21ButtonDoor
329 -> ObjectType.Ruins31ButtonDoor
330 -> ObjectType.Ruins4ButtonDoor
331 -> ObjectType.Ruins2ButtonDoor
332 -> ObjectType.RuinsSensor
333 -> ObjectType.RuinsFenceSwitch
334 -> ObjectType.RuinsLaserFence4x2
335 -> ObjectType.RuinsLaserFence6x2
336 -> ObjectType.RuinsLaserFence4x4
337 -> ObjectType.RuinsLaserFence6x4
338 -> ObjectType.RuinsPoisonBlob
339 -> ObjectType.RuinsPillarTrap
340 -> ObjectType.PopupTrapNoTech
341 -> ObjectType.RuinsCrystal
342 -> ObjectType.Monument
345 -> ObjectType.RuinsRock1
346 -> ObjectType.RuinsRock2
347 -> ObjectType.RuinsRock3
348 -> ObjectType.RuinsRock4
349 -> ObjectType.RuinsRock5
350 -> ObjectType.RuinsRock6
351 -> ObjectType.RuinsRock7
352 -> ObjectType.Poison
353 -> ObjectType.FixedBoxTypeRuins
354 -> ObjectType.RandomBoxTypeRuins
355 -> ObjectType.EnemyTypeBoxYellow
356 -> ObjectType.EnemyTypeBoxBlue
357 -> ObjectType.EmptyTypeBoxBlue
358 -> ObjectType.DestructableRock
359 -> ObjectType.PopupTrapsTechs
368 -> ObjectType.FlyingWhiteBird
369 -> ObjectType.Tower
370 -> ObjectType.FloatingRocks
371 -> ObjectType.FloatingSoul
372 -> ObjectType.Butterfly
384 -> ObjectType.LobbyGameMenu
385 -> ObjectType.LobbyWarpObject
386 -> ObjectType.Lobby1EventObjectDefaultTree
387 -> ObjectType.UnknownItem387
388 -> ObjectType.UnknownItem388
389 -> ObjectType.UnknownItem389
390 -> ObjectType.LobbyEventObjectStaticPumpkin
391 -> ObjectType.LobbyEventObject3ChristmasWindows
392 -> ObjectType.LobbyEventObjectRedAndWhiteCurtain
393 -> ObjectType.UnknownItem393
394 -> ObjectType.UnknownItem394
395 -> ObjectType.LobbyFishTank
396 -> ObjectType.LobbyEventObjectButterflies
400 -> ObjectType.UnknownItem400
401 -> ObjectType.GreyWallLow
402 -> ObjectType.SpaceshipDoor
403 -> ObjectType.GreyWallHigh
416 -> ObjectType.TempleNormalDoor
417 -> ObjectType.BreakableWallWallButUnbreakable
418 -> ObjectType.BrokenCylinderAndRubble
419 -> ObjectType.ThreeBrokenWallPiecesOnFloor
420 -> ObjectType.HighBrickCylinder
421 -> ObjectType.LyingCylinder
422 -> ObjectType.BrickConeWithFlatTop
423 -> ObjectType.BreakableTempleWall
424 -> ObjectType.TempleMapDetect
425 -> ObjectType.SmallBrownBrickRisingBridge
426 -> ObjectType.LongRisingBridgeWithPinkHighEdges
427 -> ObjectType.FourSwitchTempleDoor
448 -> ObjectType.FourButtonSpaceshipDoor
512 -> ObjectType.ItemBoxCca
513 -> ObjectType.TeleporterEp2
514 -> ObjectType.CcaDoor
515 -> ObjectType.SpecialBoxCca
516 -> ObjectType.BigCcaDoor
517 -> ObjectType.BigCcaDoorSwitch
518 -> ObjectType.LittleRock
519 -> ObjectType.Little3StoneWall
520 -> ObjectType.Medium3StoneWall
521 -> ObjectType.SpiderPlant
522 -> ObjectType.CcaAreaTeleporter
523 -> ObjectType.UnknownItem523
524 -> ObjectType.WhiteBird
525 -> ObjectType.OrangeBird
527 -> ObjectType.Saw
528 -> ObjectType.LaserDetect
529 -> ObjectType.UnknownItem529
530 -> ObjectType.UnknownItem530
531 -> ObjectType.Seagull
544 -> ObjectType.Fish
545 -> ObjectType.SeabedDoorWithBlueEdges
546 -> ObjectType.SeabedDoorAlwaysOpenNonTriggerable
547 -> ObjectType.LittleCryotube
548 -> ObjectType.WideGlassWallBreakable
549 -> ObjectType.BlueFloatingRobot
550 -> ObjectType.RedFloatingRobot
551 -> ObjectType.Dolphin
552 -> ObjectType.CaptureTrap
553 -> ObjectType.VRLink
576 -> ObjectType.UnknownItem576
640 -> ObjectType.WarpInBarbaRayRoom
672 -> ObjectType.UnknownItem672
688 -> ObjectType.GeeNest
689 -> ObjectType.LabComputerConsole
690 -> ObjectType.LabComputerConsoleGreenScreen
691 -> ObjectType.ChairYellowPillow
692 -> ObjectType.OrangeWallWithHoleInMiddle
693 -> ObjectType.GreyWallWithHoleInMiddle
694 -> ObjectType.LongTable
695 -> ObjectType.GBAStation
696 -> ObjectType.TalkLinkToSupport
697 -> ObjectType.InstaWarp
698 -> ObjectType.LabInvisibleObject
699 -> ObjectType.LabGlassWindowDoor
700 -> ObjectType.UnknownItem700
701 -> ObjectType.LabCeilingWarp
768 -> ObjectType.Ep4LightSource
769 -> ObjectType.Cactus
770 -> ObjectType.BigBrownRock
771 -> ObjectType.BreakableBrownRock
832 -> ObjectType.UnknownItem832
833 -> ObjectType.UnknownItem833
896 -> ObjectType.PoisonPlant
897 -> ObjectType.UnknownItem897
898 -> ObjectType.UnknownItem898
899 -> ObjectType.OozingDesertPlant
901 -> ObjectType.UnknownItem901
902 -> ObjectType.BigBlackRocks
903 -> ObjectType.UnknownItem903
904 -> ObjectType.UnknownItem904
905 -> ObjectType.UnknownItem905
906 -> ObjectType.UnknownItem906
907 -> ObjectType.FallingRock
908 -> ObjectType.DesertPlantHasCollision
909 -> ObjectType.DesertFixedTypeBoxBreakableCrystals
910 -> ObjectType.UnknownItem910
911 -> ObjectType.BeeHive
912 -> ObjectType.UnknownItem912
913 -> ObjectType.Heat
960 -> ObjectType.TopOfSaintMillionEgg
961 -> ObjectType.UnknownItem961
else -> ObjectType.Unknown
}

View File

@ -0,0 +1,14 @@
package world.phantasmal.lib.fileFormats.quest
import world.phantasmal.lib.fileFormats.Vec3
interface QuestEntity<Type : EntityType> {
val type: Type
/**
* Section-relative position.
*/
var position: Vec3
var rotation: Vec3
}

View File

@ -1,9 +1,76 @@
package world.phantasmal.lib.fileFormats.quest
import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt
class QuestNpc(var episode: Episode, var areaId: Int, val data: Buffer) {
class QuestNpc(var episode: Episode, var areaId: Int, val data: Buffer) : QuestEntity<NpcType> {
var typeId: Short
get() = data.getShort(0)
set(value) {
data.setShort(0, value)
}
override var type: NpcType
get() = npcTypeFromQuestNpc(this)
set(value) {
value.episode?.let { episode = it }
typeId = (value.typeId ?: 0).toShort()
when (value) {
NpcType.SaintMilion,
NpcType.SavageWolf,
NpcType.BarbarousWolf,
NpcType.PoisonLily,
NpcType.NarLily,
NpcType.PofuillySlime,
NpcType.PouillySlime,
NpcType.PoisonLily2,
NpcType.NarLily2,
NpcType.SavageWolf2,
NpcType.BarbarousWolf2,
NpcType.Kondrieu,
NpcType.Shambertin,
NpcType.SinowBeat,
NpcType.SinowGold,
NpcType.SatelliteLizard,
NpcType.Yowie,
-> special = value.special ?: false
else -> {
// Do nothing.
}
}
skin = value.skin ?: 0
if (value.areaIds.isNotEmpty() && areaId !in value.areaIds) {
areaId = value.areaIds.first()
}
}
override var position: Vec3
get() = Vec3(data.getFloat(20), data.getFloat(24), data.getFloat(28))
set(value) {
data.setFloat(20, value.x)
data.setFloat(24, value.y)
data.setFloat(28, value.z)
}
override var rotation: Vec3
get() = Vec3(
angleToRad(data.getInt(32)),
angleToRad(data.getInt(36)),
angleToRad(data.getInt(40)),
)
set(value) {
data.setInt(32, radToAngle(value.x))
data.setInt(36, radToAngle(value.y))
data.setInt(40, radToAngle(value.z))
}
/**
* Only seems to be valid for non-enemies.
*/
@ -19,6 +86,12 @@ class QuestNpc(var episode: Episode, var areaId: Int, val data: Buffer) {
data.setInt(64, value)
}
var special: Boolean
get() = data.getFloat(48).roundToInt() == 1
set(value) {
data.setFloat(48, if (value) 1f else 0f)
}
init {
require(data.size == NPC_BYTE_SIZE) {
"Data size should be $NPC_BYTE_SIZE but was ${data.size}."

View File

@ -1,13 +1,89 @@
package world.phantasmal.lib.fileFormats.quest
import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.lib.fileFormats.ninja.angleToRad
import world.phantasmal.lib.fileFormats.ninja.radToAngle
import kotlin.math.roundToInt
class QuestObject(var areaId: Int, val data: Buffer) {
var type: ObjectType
get() = TODO()
set(_) = TODO()
val scriptLabel: Int? = null // TODO Implement scriptLabel.
val scriptLabel2: Int? = null // TODO Implement scriptLabel2.
class QuestObject(var areaId: Int, val data: Buffer) : QuestEntity<ObjectType> {
var typeId: Int
get() = data.getInt(0)
set(value) {
data.setInt(0, value)
}
override var type: ObjectType
get() = objectTypeFromId(typeId)
set(value) {
typeId = value.typeId ?: -1
}
override var position: Vec3
get() = Vec3(data.getFloat(16), data.getFloat(20), data.getFloat(24))
set(value) {
data.setFloat(16, value.x)
data.setFloat(20, value.y)
data.setFloat(24, value.z)
}
override var rotation: Vec3
get() = Vec3(
angleToRad(data.getInt(28)),
angleToRad(data.getInt(32)),
angleToRad(data.getInt(36)),
)
set(value) {
data.setInt(28, radToAngle(value.x))
data.setInt(32, radToAngle(value.y))
data.setInt(36, radToAngle(value.z))
}
val scriptLabel: Int?
get() = when (type) {
ObjectType.ScriptCollision,
ObjectType.ForestConsole,
ObjectType.TalkLinkToSupport,
-> data.getInt(52)
ObjectType.RicoMessagePod,
-> data.getInt(56)
else -> null
}
val scriptLabel2: Int?
get() = if (type == ObjectType.RicoMessagePod) data.getInt(60) else null
val model: Int?
get() = when (type) {
ObjectType.Probe,
-> data.getFloat(40).roundToInt()
ObjectType.Saw,
ObjectType.LaserDetect,
-> data.getFloat(48).roundToInt()
ObjectType.Sonic,
ObjectType.LittleCryotube,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.BigBlackRocks,
ObjectType.BeeHive,
-> data.getInt(52)
ObjectType.ForestConsole,
-> data.getInt(56)
ObjectType.PrincipalWarp,
ObjectType.LaserFence,
ObjectType.LaserSquareFence,
ObjectType.LaserFenceEx,
ObjectType.LaserSquareFenceEx,
-> data.getInt(60)
else -> null
}
init {
require(data.size == OBJECT_BYTE_SIZE) {

View File

@ -0,0 +1,18 @@
package world.phantasmal.lib.fileFormats.ninja
import world.phantasmal.core.Success
import world.phantasmal.lib.test.asyncTest
import world.phantasmal.lib.test.readFile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NinjaTests {
@Test
fun can_parse_rag_rappy_model() = asyncTest {
val result = parseNj(readFile("/RagRappy.nj"))
assertTrue(result is Success)
assertEquals(1, result.value.size)
}
}

Binary file not shown.

View File

@ -6,7 +6,7 @@ import world.phantasmal.observable.value.Val
interface ListVal<E> : Val<List<E>> {
val sizeVal: Val<Int>
fun observeList(observer: ListValObserver<E>): Disposable
fun observeList(callNow: Boolean = false, observer: ListValObserver<E>): Disposable
fun sumBy(selector: (E) -> Int): Val<Int> =
fold(0) { acc, el -> acc + selector(el) }

View File

@ -1,6 +1,8 @@
package world.phantasmal.observable.value.list
fun <E> listVal(vararg elements: E): ListVal<E> = StaticListVal(elements.toList())
fun <E> mutableListVal(
elements: MutableList<E> = mutableListOf(),
extractObservables: ObservablesExtractor<E>? = null
extractObservables: ObservablesExtractor<E>? = null,
): MutableListVal<E> = SimpleListVal(elements, extractObservables)

View File

@ -97,7 +97,7 @@ class SimpleListVal<E>(
observers.add(observer)
if (callNow) {
observer(ValChangeEvent(value, value))
observer(ValChangeEvent(elements, elements))
}
return disposable {
@ -106,13 +106,17 @@ class SimpleListVal<E>(
}
}
override fun observeList(observer: ListValObserver<E>): Disposable {
override fun observeList(callNow: Boolean, observer: ListValObserver<E>): Disposable {
if (elementObservers.isEmpty() && extractObservables != null) {
replaceElementObservers(0, elementObservers.size, elements)
}
listObservers.add(observer)
if (callNow) {
observer(ListValChangeEvent.Change(0, emptyList(), elements))
}
return disposable {
listObservers.remove(observer)
disposeElementObserversIfNecessary()
@ -141,7 +145,7 @@ class SimpleListVal<E>(
observer(event)
}
val regularEvent = ValChangeEvent(value, value)
val regularEvent = ValChangeEvent(elements, elements)
observers.forEach { observer: ValObserver<List<E>> ->
observer(regularEvent)

View File

@ -0,0 +1,33 @@
package world.phantasmal.observable.value.list
import world.phantasmal.core.disposable.Disposable
import world.phantasmal.core.disposable.stubDisposable
import world.phantasmal.observable.Observer
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.ValChangeEvent
import world.phantasmal.observable.value.ValObserver
import world.phantasmal.observable.value.value
class StaticListVal<E>(elements: List<E>) : ListVal<E> {
override val sizeVal: Val<Int> = value(elements.size)
override val value: List<E> = elements
override fun observe(callNow: Boolean, observer: ValObserver<List<E>>): Disposable {
if (callNow) {
observer(ValChangeEvent(value, value))
}
return stubDisposable()
}
override fun observe(observer: Observer<List<E>>): Disposable = stubDisposable()
override fun observeList(callNow: Boolean, observer: ListValObserver<E>): Disposable {
if (callNow) {
observer(ListValChangeEvent.Change(0, emptyList(), value))
}
return stubDisposable()
}
}

View File

@ -2,6 +2,7 @@ package world.phantasmal.observable.value
import world.phantasmal.testUtils.TestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
class StaticValTests : TestSuite() {
@Test
@ -12,4 +13,15 @@ class StaticValTests : TestSuite() {
static.observe(callNow = false) {}
static.observe(callNow = true) {}
}
@Test
fun observe_respects_callNow() = test {
val static = StaticVal("test value")
var calls = 0
static.observe(callNow = false) { calls++ }
static.observe(callNow = true) { calls++ }
assertEquals(1, calls)
}
}

View File

@ -0,0 +1,40 @@
package world.phantasmal.observable.value.list
import world.phantasmal.testUtils.TestSuite
import kotlin.test.Test
import kotlin.test.assertEquals
class StaticListValTests : TestSuite() {
@Test
fun observing_StaticListVal_should_never_create_leaks() = test {
val static = StaticListVal(listOf(1, 2, 3))
static.observe {}
static.observe(callNow = false) {}
static.observe(callNow = true) {}
static.observeList(callNow = false) {}
static.observeList(callNow = true) {}
}
@Test
fun observe_respects_callNow() = test {
val static = StaticListVal(listOf(1, 2, 3))
var calls = 0
static.observe(callNow = false) { calls++ }
static.observe(callNow = true) { calls++ }
assertEquals(1, calls)
}
@Test
fun observeList_respects_callNow() = test {
val static = StaticListVal(listOf(1, 2, 3))
var calls = 0
static.observeList(callNow = false) { calls++ }
static.observeList(callNow = true) { calls++ }
assertEquals(1, calls)
}
}

View File

@ -15,9 +15,9 @@ import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.core.disposable.disposable
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.application.Application
import world.phantasmal.web.core.HttpAssetLoader
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.dom.root
@ -44,8 +44,9 @@ private fun init(): Disposable {
disposer.add(disposable { httpClient.cancel() })
val pathname = window.location.pathname
val basePath = window.location.origin +
(if (pathname.lastOrNull() == '/') pathname.dropLast(1) else pathname)
val assetBasePath = window.location.origin +
(if (pathname.lastOrNull() == '/') pathname.dropLast(1) else pathname) +
"/assets"
val scope = CoroutineScope(SupervisorJob())
disposer.add(disposable { scope.cancel() })
@ -54,7 +55,7 @@ private fun init(): Disposable {
Application(
scope,
rootElement,
HttpAssetLoader(httpClient, basePath),
AssetLoader(assetBasePath, httpClient),
disposer.add(HistoryApplicationUrl()),
createEngine = { Engine(it) }
)

View File

@ -12,11 +12,11 @@ import world.phantasmal.web.application.controllers.NavigationController
import world.phantasmal.web.application.widgets.ApplicationWidget
import world.phantasmal.web.application.widgets.MainContentWidget
import world.phantasmal.web.application.widgets.NavigationWidget
import world.phantasmal.web.core.AssetLoader
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.ApplicationUrl
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.huntOptimizer.HuntOptimizer
import world.phantasmal.web.questEditor.QuestEditor
import world.phantasmal.webui.DisposableContainer
@ -56,11 +56,19 @@ class Application(
scope,
NavigationWidget(scope, navigationController),
MainContentWidget(scope, mainContentController, mapOf(
PwTool.QuestEditor to { s ->
addDisposable(QuestEditor(s, uiStore, createEngine)).createWidget()
PwTool.QuestEditor to { widgetScope ->
addDisposable(QuestEditor(
widgetScope,
assetLoader,
createEngine
)).createWidget()
},
PwTool.HuntOptimizer to { s ->
addDisposable(HuntOptimizer(s, assetLoader, uiStore)).createWidget()
PwTool.HuntOptimizer to { widgetScope ->
addDisposable(HuntOptimizer(
widgetScope,
assetLoader,
uiStore
)).createWidget()
},
))
)

View File

@ -4,7 +4,10 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.observable.value.trueVal
import world.phantasmal.web.application.controllers.NavigationController
import world.phantasmal.web.core.dom.externalLink
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.dom.icon
import world.phantasmal.webui.widgets.Select
import world.phantasmal.webui.widgets.Widget
@ -36,6 +39,13 @@ class NavigationWidget(
)
addWidget(serverSelect.label!!)
addChild(serverSelect)
externalLink("https://github.com/DaanVandenBosch/phantasmal-world") {
className = "pw-application-navigation-github"
title = "Phantasmal World is open source, code available on GitHub"
icon(Icon.GitHub)
}
}
}
@ -67,11 +77,7 @@ class NavigationWidget(
}
.pw-application-navigation-github {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 30px;
margin: 0 6px 0 4px;
font-size: 16px;
color: var(--pw-control-text-color);
}

View File

@ -1,18 +0,0 @@
package world.phantasmal.web.core
import io.ktor.client.*
import io.ktor.client.request.*
import world.phantasmal.web.core.dto.QuestDto
import world.phantasmal.web.core.models.Server
interface AssetLoader {
suspend fun getQuests(server: Server): List<QuestDto>
}
class HttpAssetLoader(
private val httpClient: HttpClient,
private val basePath: String,
) : AssetLoader {
override suspend fun getQuests(server: Server): List<QuestDto> =
httpClient.get("$basePath/assets/quests.${server.slug}.json")
}

View File

@ -0,0 +1,13 @@
package world.phantasmal.web.core.dom
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.Node
import world.phantasmal.webui.dom.appendHtmlEl
fun Node.externalLink(href: String, block: HTMLAnchorElement.() -> Unit) =
appendHtmlEl<HTMLAnchorElement>("A") {
target = "_blank"
rel = "noopener noreferrer"
this.href = href
block()
}

View File

@ -0,0 +1,21 @@
package world.phantasmal.web.core.loading
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import org.khronos.webgl.ArrayBuffer
class AssetLoader(val basePath: String, val httpClient: HttpClient) {
suspend inline fun <reified T> load(path: String): T =
httpClient.get("$basePath$path")
suspend fun loadArrayBuffer(path: String): ArrayBuffer {
val response = load<HttpResponse>(path)
val channel = response.content
val arrayBuffer = ArrayBuffer(response.contentLength()?.toInt() ?: channel.availableForRead)
channel.readFully(arrayBuffer, 0, arrayBuffer.byteLength)
check(channel.availableForRead == 0) { "Couldn't read all data." }
return arrayBuffer
}
}

View File

@ -2,25 +2,28 @@ package world.phantasmal.web.core.rendering
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.externals.Scene
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.externals.babylon.Scene
abstract class Renderer(
protected val canvas: HTMLCanvasElement,
createEngine: (HTMLCanvasElement) -> Engine,
protected val engine: Engine,
) : TrackedDisposable() {
protected val engine = createEngine(canvas)
protected val scene = Scene(engine)
init {
println(engine.description)
engine.runRenderLoop {
scene.render()
}
}
override fun internalDispose() {
// TODO: Clean up Babylon resources.
scene.dispose()
engine.dispose()
super.internalDispose()
}
fun scheduleRender() {
// TODO: Remove scheduleRender?
}
}

View File

@ -0,0 +1,10 @@
package world.phantasmal.web.core.rendering.conversion
import world.phantasmal.lib.fileFormats.Vec2
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.web.externals.babylon.Vector2
import world.phantasmal.web.externals.babylon.Vector3
fun vec2ToBabylon(v: Vec2): Vector2 = Vector2(v.x.toDouble(), v.y.toDouble())
fun vec3ToBabylon(v: Vec3): Vector3 = Vector3(v.x.toDouble(), v.y.toDouble(), v.z.toDouble())

View File

@ -0,0 +1,212 @@
package world.phantasmal.web.core.rendering.conversion
import mu.KotlinLogging
import world.phantasmal.lib.fileFormats.Vec3
import world.phantasmal.lib.fileFormats.ninja.NinjaModel
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.NjcmModel
import world.phantasmal.lib.fileFormats.ninja.XjModel
import world.phantasmal.web.externals.babylon.*
import kotlin.math.cos
import kotlin.math.sin
private val logger = KotlinLogging.logger {}
private val DEFAULT_NORMAL = Vector3.Up()
private val DEFAULT_UV = Vector2.Zero()
private val NO_TRANSLATION = Vector3.Zero()
private val NO_ROTATION = Quaternion.Identity()
private val NO_SCALE = Vector3.One()
// TODO: take into account different kinds of meshes/vertices (with or without normals, uv, etc.).
fun ninjaObjectToVertexData(ninjaObject: NinjaObject<*>): VertexData =
NinjaToVertexDataConverter(VertexDataBuilder()).convert(ninjaObject)
private class NinjaToVertexDataConverter(private val builder: VertexDataBuilder) {
private val vertexHolder = VertexHolder()
private var boneIndex = 0
fun convert(ninjaObject: NinjaObject<*>): VertexData {
objectToVertexData(ninjaObject, Matrix.Identity())
return builder.build()
}
private fun objectToVertexData(obj: NinjaObject<*>, parentMatrix: Matrix) {
val ef = obj.evaluationFlags
val matrix = Matrix.Compose(
if (ef.noScale) NO_SCALE else vec3ToBabylon(obj.scale),
if (ef.noRotate) NO_ROTATION else eulerToQuat(obj.rotation, ef.zxyRotationOrder),
if (ef.noTranslate) NO_TRANSLATION else vec3ToBabylon(obj.position),
)
parentMatrix.multiplyToRef(matrix, matrix)
if (!ef.hidden) {
obj.model?.let { model ->
modelToVertexData(model, matrix)
}
}
boneIndex++
if (!ef.breakChildTrace) {
obj.children.forEach { child ->
objectToVertexData(child, matrix)
}
}
}
private fun modelToVertexData(model: NinjaModel, matrix: Matrix) =
when (model) {
is NjcmModel -> njcmModelToVertexData(model, matrix)
is XjModel -> xjModelToVertexData(model, matrix)
}
private fun njcmModelToVertexData(model: NjcmModel, matrix: Matrix) {
val normalMatrix = Matrix.Identity()
matrix.toNormalMatrix(normalMatrix)
val newVertices = model.vertices.map { vertex ->
vertex?.let {
val position = vec3ToBabylon(vertex.position)
val normal = vertex.normal?.let(::vec3ToBabylon) ?: Vector3.Up()
Vector3.TransformCoordinatesToRef(position, matrix, position)
Vector3.TransformNormalToRef(normal, normalMatrix, normal)
Vertex(
boneIndex,
position,
normal,
vertex.boneWeight,
vertex.boneWeightStatus,
vertex.calcContinue,
)
}
}
vertexHolder.add(newVertices)
for (mesh in model.meshes) {
val startIndexCount = builder.indexCount
var i = 0
for (meshVertex in mesh.vertices) {
val vertices = vertexHolder.get(meshVertex.index.toInt())
if (vertices.isEmpty()) {
logger.debug {
"Mesh refers to nonexistent vertex with index ${meshVertex.index}."
}
} else {
val vertex = vertices.last()
val normal =
vertex.normal ?: meshVertex.normal?.let(::vec3ToBabylon) ?: DEFAULT_NORMAL
val index = builder.vertexCount
builder.addVertex(
vertex.position,
normal,
meshVertex.texCoords?.let(::vec2ToBabylon) ?: DEFAULT_UV
)
if (i >= 2) {
if (i % 2 == if (mesh.clockwiseWinding) 1 else 0) {
builder.addIndex(index - 2)
builder.addIndex(index - 1)
builder.addIndex(index)
} else {
builder.addIndex(index - 2)
builder.addIndex(index)
builder.addIndex(index - 1)
}
}
val boneIndices = IntArray(4)
val boneWeights = FloatArray(4)
for (v in vertices) {
boneIndices[v.boneWeightStatus] = v.boneIndex
boneWeights[v.boneWeightStatus] = v.boneWeight
}
val totalWeight = boneWeights.sum()
for (j in boneIndices.indices) {
builder.addBoneWeight(
boneIndices[j],
if (totalWeight > 0f) boneWeights[j] / totalWeight else 0f
)
}
i++
}
}
// TODO: support multiple materials
// builder.addGroup(
// startIndexCount
// )
}
}
private fun xjModelToVertexData(model: XjModel, matrix: Matrix) {}
}
private class Vertex(
val boneIndex: Int,
val position: Vector3,
val normal: Vector3?,
val boneWeight: Float,
val boneWeightStatus: Int,
val calcContinue: Boolean,
)
private class VertexHolder {
private val stack = mutableListOf<MutableList<Vertex>>()
fun add(vertices: List<Vertex?>) {
vertices.forEachIndexed { i, vertex ->
if (i >= stack.size) {
stack.add(mutableListOf())
}
if (vertex != null) {
stack[i].add(vertex)
}
}
}
fun get(index: Int): List<Vertex> = stack[index]
}
private fun eulerToQuat(angles: Vec3, zxyRotationOrder: Boolean): Quaternion {
val x = angles.x.toDouble()
val y = angles.y.toDouble()
val z = angles.z.toDouble()
val c1 = cos(x / 2)
val c2 = cos(y / 2)
val c3 = cos(z / 2)
val s1 = sin(x / 2)
val s2 = sin(y / 2)
val s3 = sin(z / 2)
return if (zxyRotationOrder) {
Quaternion(
s1 * c2 * c3 - c1 * s2 * s3,
c1 * s2 * c3 + s1 * c2 * s3,
c1 * c2 * s3 + s1 * s2 * c3,
c1 * c2 * c3 - s1 * s2 * s3,
)
} else {
Quaternion(
s1 * c2 * c3 - c1 * s2 * s3,
c1 * s2 * c3 + s1 * c2 * s3,
c1 * c2 * s3 - s1 * s2 * c3,
c1 * c2 * c3 + s1 * s2 * s3,
)
}
}

View File

@ -0,0 +1,78 @@
package world.phantasmal.web.core.rendering.conversion
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.khronos.webgl.set
import world.phantasmal.web.externals.babylon.Vector2
import world.phantasmal.web.externals.babylon.Vector3
import world.phantasmal.web.externals.babylon.VertexData
class VertexDataBuilder {
private val positions = mutableListOf<Vector3>()
private val normals = mutableListOf<Vector3>()
private val uvs = mutableListOf<Vector2>()
private val indices = mutableListOf<Short>()
private val boneIndices = mutableListOf<Short>()
private val boneWeights = mutableListOf<Float>()
val vertexCount: Int
get() = positions.size
val indexCount: Int
get() = indices.size
fun addVertex(position: Vector3, normal: Vector3, uv: Vector2) {
positions.add(position)
normals.add(normal)
uvs.add(uv)
}
fun addIndex(index: Int) {
indices.add(index.toShort())
}
fun addBoneWeight(index: Int, weight: Float) {
boneIndices.add(index.toShort())
boneWeights.add(weight)
}
// TODO: support multiple materials
// fun addGroup(
// offset: Int,
// size: Int,
// textureId: Int?,
// alpha: Boolean = false,
// additiveBlending: Boolean = false,
// ) {
//
// }
fun build(): VertexData {
val positions = Float32Array(3 * positions.size)
val normals = Float32Array(3 * normals.size)
val uvs = Float32Array(2 * uvs.size)
for (i in this.positions.indices) {
val pos = this.positions[i]
positions[3 * i] = pos.x.toFloat()
positions[3 * i + 1] = pos.y.toFloat()
positions[3 * i + 2] = pos.z.toFloat()
val normal = this.normals[i]
normals[3 * i] = normal.x.toFloat()
normals[3 * i + 1] = normal.y.toFloat()
normals[3 * i + 2] = normal.z.toFloat()
val uv = this.uvs[i]
uvs[2 * i] = uv.x.toFloat()
uvs[2 * i + 1] = uv.y.toFloat()
}
val data = VertexData()
data.positions = positions
data.normals = normals
data.uvs = uvs
data.indices = Uint16Array(indices.toTypedArray())
return data
}
}

View File

@ -4,8 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.webui.newJsObject
import world.phantasmal.web.externals.GoldenLayout
import world.phantasmal.webui.obj
import world.phantasmal.web.externals.goldenLayout.GoldenLayout
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
@ -61,13 +61,13 @@ class DockWidget(
val idToCreate = mutableMapOf<String, (CoroutineScope) -> Widget>()
val config = newJsObject<GoldenLayout.Config> {
settings = newJsObject<GoldenLayout.Settings> {
val config = obj<GoldenLayout.Config> {
settings = obj<GoldenLayout.Settings> {
showPopoutIcon = false
showMaximiseIcon = false
showCloseIcon = false
}
dimensions = newJsObject<GoldenLayout.Dimensions> {
dimensions = obj<GoldenLayout.Dimensions> {
headerHeight = HEADER_HEIGHT
}
content = arrayOf(
@ -120,7 +120,7 @@ class DockWidget(
is DockedWidget -> {
idToCreate[item.id] = item.createWidget
newJsObject<GoldenLayout.ComponentConfig> {
obj<GoldenLayout.ComponentConfig> {
title = item.title
type = "component"
componentName = item.id
@ -134,7 +134,7 @@ class DockWidget(
}
is DockedContainer ->
newJsObject {
obj {
type = itemType
content = Array(item.items.size) { toConfigContent(item.items[it], idToCreate) }

View File

@ -3,22 +3,22 @@ package world.phantasmal.web.core.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.webui.dom.canvas
import world.phantasmal.webui.widgets.Widget
import kotlin.math.floor
class RendererWidget(
scope: CoroutineScope,
private val createEngine: (HTMLCanvasElement) -> Engine,
private val createRenderer: (HTMLCanvasElement) -> Renderer,
) : Widget(scope) {
override fun Node.createElement() =
canvas {
className = "pw-core-renderer"
tabIndex = -1
observeResize()
addDisposable(QuestRenderer(this, createEngine))
addDisposable(createRenderer(this))
}
override fun resized(width: Double, height: Double) {
@ -35,6 +35,7 @@ class RendererWidget(
.pw-core-renderer {
width: 100%;
height: 100%;
outline: none;
}
""".trimIndent())
}

View File

@ -1,105 +0,0 @@
@file:JsModule("@babylonjs/core")
@file:JsNonModule
package world.phantasmal.web.externals
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.HTMLElement
external class Vector3(x: Double, y: Double, z: Double) {
var x: Double
var y: Double
var z: Double
fun toQuaternion(): Quaternion
fun addInPlace(otherVector: Vector3): Vector3
fun addInPlaceFromFloats(x: Double, y: Double, z: Double): Vector3
companion object {
fun Zero(): Vector3
}
}
external class Quaternion
open external class ThinEngine {
val description: String
/**
* Register and execute a render loop. The engine can have more than one render function
* @param renderFunction defines the function to continuously execute
*/
fun runRenderLoop(renderFunction: () -> Unit)
}
external class Engine(
canvasOrContext: HTMLCanvasElement?,
antialias: Boolean = definedExternally,
) : ThinEngine
external class Scene(engine: Engine) {
fun render()
}
open external class Node {
/**
* Releases resources associated with this node.
* @param doNotRecurse Set to true to not recurse into each children (recurse into each children by default)
* @param disposeMaterialAndTextures Set to true to also dispose referenced materials and textures (false by default)
*/
fun dispose(
doNotRecurse: Boolean = definedExternally,
disposeMaterialAndTextures: Boolean = definedExternally,
)
}
open external class Camera : Node {
fun attachControl(element: HTMLElement, noPreventDefault: Boolean = definedExternally)
}
open external class TargetCamera : Camera
/**
* @param setActiveOnSceneIfNoneActive default true
*/
external class ArcRotateCamera(
name: String,
alpha: Double,
beta: Double,
radius: Double,
target: Vector3,
scene: Scene,
setActiveOnSceneIfNoneActive: Boolean = definedExternally,
) : TargetCamera
abstract external class Light : Node
external class HemisphericLight(name: String, direction: Vector3, scene: Scene) : Light
open external class TransformNode : Node
abstract external class AbstractMesh : TransformNode
external class Mesh : AbstractMesh
external class MeshBuilder {
companion object {
interface CreateCylinderOptions {
var height: Double
var diameterTop: Double
var diameterBottom: Double
var diameter: Double
var tessellation: Double
var subdivisions: Double
var arc: Double
}
fun CreateCylinder(
name: String,
options: CreateCylinderOptions,
scene: Scene? = definedExternally,
): Mesh
}
}

View File

@ -0,0 +1,228 @@
@file:JsModule("@babylonjs/core")
@file:JsNonModule
@file:Suppress("FunctionName", "unused")
package world.phantasmal.web.externals.babylon
import org.khronos.webgl.Float32Array
import org.khronos.webgl.Uint16Array
import org.w3c.dom.HTMLCanvasElement
external class Vector2(x: Double, y: Double) {
var x: Double
var y: Double
fun addInPlace(otherVector: Vector2): Vector2
fun addInPlaceFromFloats(x: Double, y: Double): Vector2
fun copyFrom(source: Vector2): Vector2
companion object {
fun Zero(): Vector2
}
}
external class Vector3(x: Double, y: Double, z: Double) {
var x: Double
var y: Double
var z: Double
fun toQuaternion(): Quaternion
fun addInPlace(otherVector: Vector3): Vector3
fun addInPlaceFromFloats(x: Double, y: Double, z: Double): Vector3
fun copyFrom(source: Vector3): Vector3
companion object {
fun One(): Vector3
fun Up(): Vector3
fun Zero(): Vector3
fun TransformCoordinates(vector: Vector3, transformation: Matrix): Vector3
fun TransformCoordinatesToRef(vector: Vector3, transformation: Matrix, result: Vector3)
fun TransformNormal(vector: Vector3, transformation: Matrix): Vector3
fun TransformNormalToRef(vector: Vector3, transformation: Matrix, result: Vector3)
}
}
external class Quaternion(
x: Double = definedExternally,
y: Double = definedExternally,
z: Double = definedExternally,
w: Double = definedExternally,
) {
/**
* Multiplies two quaternions
* @return a new quaternion set as the multiplication result of the current one with the given one "q1"
*/
fun multiply(q1: Quaternion): Quaternion
/**
* Updates the current quaternion with the multiplication of itself with the given one "q1"
* @return the current, updated quaternion
*/
fun multiplyInPlace(q1: Quaternion): Quaternion
/**
* Sets the given "result" as the the multiplication result of the current one with the given one "q1"
* @return the current quaternion
*/
fun multiplyToRef(q1: Quaternion, result: Quaternion): Quaternion
companion object {
fun Identity(): Quaternion
fun FromEulerAngles(x: Double, y: Double, z: Double): Quaternion
fun RotationYawPitchRoll(yaw: Double, pitch: Double, roll: Double): Quaternion
}
}
external class Matrix {
fun multiply(other: Matrix): Matrix
fun multiplyToRef(other: Matrix, result: Matrix): Matrix
fun toNormalMatrix(ref: Matrix)
companion object {
fun Identity(): Matrix
fun Compose(scale: Vector3, rotation: Quaternion, translation: Vector3): Matrix
}
}
open external class ThinEngine {
val description: String
/**
* Register and execute a render loop. The engine can have more than one render function
* @param renderFunction defines the function to continuously execute
*/
fun runRenderLoop(renderFunction: () -> Unit)
fun dispose()
}
external class Engine(
canvasOrContext: HTMLCanvasElement?,
antialias: Boolean = definedExternally,
) : ThinEngine
external class Scene(engine: Engine) {
fun render()
fun addLight(light: Light)
fun addMesh(newMesh: AbstractMesh, recursive: Boolean? = definedExternally)
fun addTransformNode(newTransformNode: TransformNode)
fun removeLight(toRemove: Light)
fun removeMesh(toRemove: TransformNode, recursive: Boolean? = definedExternally)
fun removeTransformNode(toRemove: TransformNode)
fun dispose()
}
open external class Node {
var metadata: Any?
var parent: Node?
var position: Vector3
var rotation: Vector3
var scaling: Vector3
fun setEnabled(value: Boolean)
/**
* Releases resources associated with this node.
* @param doNotRecurse Set to true to not recurse into each children (recurse into each children by default)
* @param disposeMaterialAndTextures Set to true to also dispose referenced materials and textures (false by default)
*/
fun dispose(
doNotRecurse: Boolean = definedExternally,
disposeMaterialAndTextures: Boolean = definedExternally,
)
}
open external class Camera : Node {
fun attachControl(noPreventDefault: Boolean = definedExternally)
}
open external class TargetCamera : Camera
/**
* @param setActiveOnSceneIfNoneActive default true
*/
external class ArcRotateCamera(
name: String,
alpha: Double,
beta: Double,
radius: Double,
target: Vector3,
scene: Scene,
setActiveOnSceneIfNoneActive: Boolean = definedExternally,
) : TargetCamera {
var inertia: Double
var angularSensibilityX: Double
var angularSensibilityY: Double
var panningInertia: Double
var panningSensibility: Double
var panningAxis: Vector3
var pinchDeltaPercentage: Double
var wheelDeltaPercentage: Double
fun attachControl(
element: HTMLCanvasElement,
noPreventDefault: Boolean,
useCtrlForPanning: Boolean,
panningMouseButton: Int,
)
}
abstract external class Light : Node
external class HemisphericLight(name: String, direction: Vector3, scene: Scene) : Light
open external class TransformNode(
name: String,
scene: Scene? = definedExternally,
isPure: Boolean = definedExternally,
) : Node {
}
abstract external class AbstractMesh : TransformNode
external class Mesh(
name: String,
scene: Scene? = definedExternally,
parent: Node? = definedExternally,
source: Mesh? = definedExternally,
doNotCloneChildren: Boolean = definedExternally,
clonePhysicsImpostor: Boolean = definedExternally,
) : AbstractMesh {
fun createInstance(name: String): InstancedMesh
}
external class InstancedMesh : AbstractMesh
external class MeshBuilder {
companion object {
interface CreateCylinderOptions {
var height: Double
var diameterTop: Double
var diameterBottom: Double
var diameter: Double
var tessellation: Double
var subdivisions: Double
var arc: Double
}
fun CreateCylinder(
name: String,
options: CreateCylinderOptions,
scene: Scene? = definedExternally,
): Mesh
}
}
external class VertexData {
var positions: Float32Array? // number[] | Float32Array
var normals: Float32Array? // number[] | Float32Array
var uvs: Float32Array? // number[] | Float32Array
var indices: Uint16Array? // number[] | Int32Array | Uint32Array | Uint16Array
fun applyToMesh(mesh: Mesh, updatable: Boolean = definedExternally): VertexData
}

View File

@ -1,4 +1,6 @@
package world.phantasmal.web.externals
@file:Suppress("unused")
package world.phantasmal.web.externals.goldenLayout
import org.w3c.dom.Element

View File

@ -1,7 +1,7 @@
package world.phantasmal.web.huntOptimizer
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.web.core.AssetLoader
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.controllers.HuntOptimizerController
import world.phantasmal.web.huntOptimizer.controllers.MethodsController

View File

@ -1,4 +1,4 @@
package world.phantasmal.web.core.dto
package world.phantasmal.web.huntOptimizer.dto
import kotlinx.serialization.Serializable

View File

@ -7,11 +7,12 @@ import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.web.core.AssetLoader
import world.phantasmal.web.core.IoDispatcher
import world.phantasmal.web.core.UiDispatcher
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.models.Server
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.huntOptimizer.dto.QuestDto
import world.phantasmal.web.huntOptimizer.models.HuntMethodModel
import world.phantasmal.web.huntOptimizer.models.SimpleQuestModel
import world.phantasmal.webui.stores.Store
@ -34,9 +35,10 @@ class HuntMethodStore(
private fun loadMethods(server: Server) {
launch(IoDispatcher) {
val quests = assetLoader.getQuests(server)
val quests = assetLoader.load<List<QuestDto>>("/quests.${server.slug}.json")
val methods = quests.asSequence()
val methods = quests
.asSequence()
.filter {
when (it.id) {
// The following quests are left out because their enemies don't drop

View File

@ -2,10 +2,13 @@ package world.phantasmal.web.questEditor
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
import world.phantasmal.web.questEditor.controllers.QuestInfoController
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.rendering.QuestEditorMeshManager
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.web.questEditor.stores.QuestEditorStore
import world.phantasmal.web.questEditor.widgets.QuestEditorRendererWidget
import world.phantasmal.web.questEditor.widgets.QuestEditorToolbar
@ -16,11 +19,13 @@ import world.phantasmal.webui.widgets.Widget
class QuestEditor(
private val scope: CoroutineScope,
uiStore: UiStore,
private val assetLoader: AssetLoader,
private val createEngine: (HTMLCanvasElement) -> Engine,
) : DisposableContainer() {
// Stores
private val questEditorStore = addDisposable(QuestEditorStore(scope))
// Controllers
private val toolbarController =
addDisposable(QuestEditorToolbarController(scope, questEditorStore))
private val questInfoController = addDisposable(QuestInfoController(scope, questEditorStore))
@ -30,6 +35,18 @@ class QuestEditor(
scope,
QuestEditorToolbar(scope, toolbarController),
{ scope -> QuestInfoWidget(scope, questInfoController) },
{ scope -> QuestEditorRendererWidget(scope, createEngine) }
{ scope -> QuestEditorRendererWidget(scope, ::createQuestEditorRenderer) }
)
private fun createQuestEditorRenderer(canvas: HTMLCanvasElement): QuestRenderer =
QuestRenderer(canvas, createEngine(canvas)) { renderer, scene ->
QuestEditorMeshManager(
scope,
questEditorStore.currentQuest,
questEditorStore.currentArea,
questEditorStore.selectedWave,
renderer,
EntityAssetLoader(scope, assetLoader, scene)
)
}
}

View File

@ -0,0 +1,392 @@
package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import mu.KotlinLogging
import org.khronos.webgl.ArrayBuffer
import world.phantasmal.core.PwResult
import world.phantasmal.core.Success
import world.phantasmal.lib.Endianness
import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.fileFormats.ninja.NinjaModel
import world.phantasmal.lib.fileFormats.ninja.NinjaObject
import world.phantasmal.lib.fileFormats.ninja.parseNj
import world.phantasmal.lib.fileFormats.ninja.parseXj
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.rendering.conversion.ninjaObjectToVertexData
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.webui.DisposableContainer
import world.phantasmal.webui.obj
private val logger = KotlinLogging.logger {}
class EntityAssetLoader(
private val scope: CoroutineScope,
private val assetLoader: AssetLoader,
private val scene: Scene,
) : DisposableContainer() {
private val defaultMesh = MeshBuilder.CreateCylinder("Entity", obj {
diameter = 6.0
height = 20.0
}, scene).apply {
setEnabled(false)
position = Vector3(0.0, 10.0, 0.0)
}
private val meshCache = addDisposable(LoadingCache<Pair<EntityType, Int?>, Mesh>())
suspend fun loadMesh(type: EntityType, model: Int?): Mesh {
return meshCache.getOrPut(Pair(type, model)) {
scope.async {
try {
loadGeometry(type, model)?.let { vertexData ->
// TODO: Remove this check when XJ models are parsed.
if (vertexData.indices == null || vertexData.indices!!.length == 0) {
defaultMesh
} else {
val mesh = Mesh("${type.uniqueName}${model?.let { "-$it" }}", scene)
mesh.setEnabled(false)
vertexData.applyToMesh(mesh)
mesh
}
} ?: defaultMesh
} catch (e: Throwable) {
logger.error(e) { "Couldn't load mesh for $type (model: $model)." }
defaultMesh
}
}
}.await()
}
private suspend fun loadGeometry(type: EntityType, model: Int?): VertexData? {
val geomFormat = entityTypeToGeometryFormat(type)
val geomParts = geometryParts(type).mapNotNull { suffix ->
entityTypeToPath(type, AssetType.Geometry, suffix, model, geomFormat)?.let { path ->
val data = assetLoader.loadArrayBuffer(path)
Pair(path, data)
}
}
return when (geomFormat) {
GeomFormat.Nj -> parseGeometry(type, geomParts, ::parseNj)
GeomFormat.Xj -> parseGeometry(type, geomParts, ::parseXj)
}
}
private fun <Model : NinjaModel> parseGeometry(
type: EntityType,
parts: List<Pair<String, ArrayBuffer>>,
parse: (Cursor) -> PwResult<List<NinjaObject<Model>>>,
): VertexData? {
val njObjects = parts.flatMap { (path, data) ->
val njObjects = parse(data.cursor(Endianness.Little))
if (njObjects is Success && njObjects.value.isNotEmpty()) {
njObjects.value
} else {
logger.warn { "Couldn't parse $path for $type." }
emptyList()
}
}
if (njObjects.isEmpty()) {
return null
}
val njObject = njObjects.first()
njObject.evaluationFlags.breakChildTrace = false
for (njObj in njObjects.drop(1)) {
njObject.addChild(njObj)
}
return ninjaObjectToVertexData(njObject)
}
}
private enum class AssetType {
Geometry, Texture
}
private enum class GeomFormat {
Nj, Xj
}
/**
* Returns the suffix of each geometry part.
*/
private fun geometryParts(type: EntityType): List<String?> =
when (type) {
ObjectType.Teleporter -> listOf("", "-2")
ObjectType.Warp -> listOf("", "-2")
ObjectType.BossTeleporter -> listOf("", "-2")
ObjectType.QuestWarp -> listOf("", "-2")
ObjectType.Epilogue -> listOf("", "-2")
ObjectType.MainRagolTeleporter -> listOf("", "-2")
ObjectType.PrincipalWarp -> listOf("", "-2")
ObjectType.TeleporterDoor -> listOf("", "-2")
ObjectType.EasterEgg -> listOf("", "-2")
ObjectType.ValentinesHeart -> listOf("", "-2", "-3")
ObjectType.ChristmasTree -> listOf("", "-2", "-3", "-4")
ObjectType.TwentyFirstCentury -> listOf("", "-2")
ObjectType.WelcomeBoard -> listOf("") // TODO: position part 2 correctly.
ObjectType.ForestDoor -> listOf("", "-2", "-3", "-4", "-5")
ObjectType.ForestSwitch -> listOf("", "-2", "-3")
ObjectType.LaserFence -> listOf("", "-2")
ObjectType.LaserSquareFence -> listOf("", "-2")
ObjectType.ForestLaserFenceSwitch -> listOf("", "-2", "-3")
ObjectType.Probe -> listOf("-0") // TODO: use correct part.
ObjectType.RandomTypeBox1 -> listOf("-2") // What are the other two parts for?
ObjectType.BlackSlidingDoor -> listOf("", "-2")
ObjectType.EnergyBarrier -> listOf("", "-2")
ObjectType.SwitchNoneDoor -> listOf("", "-2")
ObjectType.EnemyBoxGrey -> listOf("-2") // What are the other two parts for?
ObjectType.FixedTypeBox -> listOf("-3") // What are the other three parts for?
ObjectType.EnemyBoxBrown -> listOf("-3") // What are the other three parts for?
ObjectType.LaserFenceEx -> listOf("", "-2")
ObjectType.LaserSquareFenceEx -> listOf("", "-2")
ObjectType.CavesSmashingPillar -> listOf("", "-3") // What's part 2 for?
ObjectType.RobotRechargeStation -> listOf("", "-2")
ObjectType.RuinsTeleporter -> listOf("", "-2", "-3", "-4")
ObjectType.RuinsWarpSiteToSite -> listOf("", "-2")
ObjectType.RuinsSwitch -> listOf("", "-2")
ObjectType.RuinsPillarTrap -> listOf("", "-2", "-3", "-4")
ObjectType.RuinsCrystal -> listOf("", "-2", "-3")
ObjectType.FloatingRocks -> listOf("-0")
ObjectType.ItemBoxCca -> listOf("", "-3") // What are the other two parts for?
ObjectType.TeleporterEp2 -> listOf("", "-2")
ObjectType.CcaDoor -> listOf("", "-2")
ObjectType.SpecialBoxCca -> listOf("", "-4") // What are the other two parts for?
ObjectType.BigCcaDoor -> listOf("", "-2", "-3", "-4")
ObjectType.BigCcaDoorSwitch -> listOf("", "-2")
ObjectType.LaserDetect -> listOf("", "-2") // TODO: use correct part.
ObjectType.LabCeilingWarp -> listOf("", "-2")
ObjectType.BigBrownRock -> listOf("-0") // TODO: use correct part.
ObjectType.BigBlackRocks -> listOf("")
ObjectType.BeeHive -> listOf("", "-0", "-1")
else -> listOf(null)
}
private fun entityTypeToGeometryFormat(type: EntityType): GeomFormat =
when (type) {
is NpcType -> {
when (type) {
NpcType.Dubswitch -> GeomFormat.Xj
else -> GeomFormat.Nj
}
}
is ObjectType -> {
when (type) {
ObjectType.EasterEgg,
ObjectType.ChristmasTree,
ObjectType.ChristmasWreath,
ObjectType.TwentyFirstCentury,
ObjectType.Sonic,
ObjectType.WelcomeBoard,
ObjectType.FloatingJellyfish,
ObjectType.RuinsSeal,
ObjectType.Dolphin,
ObjectType.Cactus,
ObjectType.BigBrownRock,
ObjectType.PoisonPlant,
ObjectType.BigBlackRocks,
ObjectType.FallingRock,
ObjectType.DesertFixedTypeBoxBreakableCrystals,
ObjectType.BeeHive,
-> GeomFormat.Nj
else -> GeomFormat.Xj
}
}
else -> {
error("$type not supported.")
}
}
private fun entityTypeToPath(
type: EntityType,
assetType: AssetType,
suffix: String?,
model: Int?,
geomFormat: GeomFormat,
): String? {
val fullSuffix = when {
suffix != null -> suffix
model != null -> "-$model"
else -> ""
}
val extension = when (assetType) {
AssetType.Geometry -> when (geomFormat) {
GeomFormat.Nj -> "nj"
GeomFormat.Xj -> "xj"
}
AssetType.Texture -> "xvm"
}
return when (type) {
is NpcType -> {
when (type) {
// We don't have a model for these NPCs.
NpcType.Unknown,
NpcType.Migium,
NpcType.Hidoom,
NpcType.VolOptPart1,
NpcType.DeathGunner,
NpcType.StRappy,
NpcType.HalloRappy,
NpcType.EggRappy,
NpcType.Migium2,
NpcType.Hidoom2,
NpcType.Recon,
-> null
// Episode II VR Temple
NpcType.Hildebear2 ->
entityTypeToPath(NpcType.Hildebear, assetType, suffix, model, geomFormat)
NpcType.Hildeblue2 ->
entityTypeToPath(NpcType.Hildeblue, assetType, suffix, model, geomFormat)
NpcType.RagRappy2 ->
entityTypeToPath(NpcType.RagRappy, assetType, suffix, model, geomFormat)
NpcType.Monest2 ->
entityTypeToPath(NpcType.Monest, assetType, suffix, model, geomFormat)
NpcType.Mothmant2 ->
entityTypeToPath(NpcType.Mothmant, assetType, suffix, model, geomFormat)
NpcType.PoisonLily2 ->
entityTypeToPath(NpcType.PoisonLily, assetType, suffix, model, geomFormat)
NpcType.NarLily2 ->
entityTypeToPath(NpcType.NarLily, assetType, suffix, model, geomFormat)
NpcType.GrassAssassin2 ->
entityTypeToPath(NpcType.GrassAssassin, assetType, suffix, model, geomFormat)
NpcType.Dimenian2 ->
entityTypeToPath(NpcType.Dimenian, assetType, suffix, model, geomFormat)
NpcType.LaDimenian2 ->
entityTypeToPath(NpcType.LaDimenian, assetType, suffix, model, geomFormat)
NpcType.SoDimenian2 ->
entityTypeToPath(NpcType.SoDimenian, assetType, suffix, model, geomFormat)
NpcType.DarkBelra2 ->
entityTypeToPath(NpcType.DarkBelra, assetType, suffix, model, geomFormat)
// Episode II VR Spaceship
NpcType.SavageWolf2 ->
entityTypeToPath(NpcType.SavageWolf, assetType, suffix, model, geomFormat)
NpcType.BarbarousWolf2 ->
entityTypeToPath(NpcType.BarbarousWolf, assetType, suffix, model, geomFormat)
NpcType.PanArms2 ->
entityTypeToPath(NpcType.PanArms, assetType, suffix, model, geomFormat)
NpcType.Dubchic2 ->
entityTypeToPath(NpcType.Dubchic, assetType, suffix, model, geomFormat)
NpcType.Gilchic2 ->
entityTypeToPath(NpcType.Gilchic, assetType, suffix, model, geomFormat)
NpcType.Garanz2 ->
entityTypeToPath(NpcType.Garanz, assetType, suffix, model, geomFormat)
NpcType.Dubswitch2 ->
entityTypeToPath(NpcType.Dubswitch, assetType, suffix, model, geomFormat)
NpcType.Delsaber2 ->
entityTypeToPath(NpcType.Delsaber, assetType, suffix, model, geomFormat)
NpcType.ChaosSorcerer2 ->
entityTypeToPath(NpcType.ChaosSorcerer, assetType, suffix, model, geomFormat)
else -> "/npcs/${type.name}${fullSuffix}.$extension"
}
}
is ObjectType -> {
when (type) {
// We don't have a model for these objects.
ObjectType.Unknown,
ObjectType.PlayerSet,
ObjectType.Particle,
ObjectType.LightCollision,
ObjectType.EnvSound,
ObjectType.FogCollision,
ObjectType.EventCollision,
ObjectType.CharaCollision,
ObjectType.ObjRoomID,
ObjectType.LensFlare,
ObjectType.ScriptCollision,
ObjectType.MapCollision,
ObjectType.ScriptCollisionA,
ObjectType.ItemLight,
ObjectType.RadarCollision,
ObjectType.FogCollisionSW,
ObjectType.ImageBoard,
ObjectType.UnknownItem29,
ObjectType.UnknownItem30,
ObjectType.UnknownItem31,
ObjectType.MenuActivation,
ObjectType.BoxDetectObject,
ObjectType.SymbolChatObject,
ObjectType.TouchPlateObject,
ObjectType.TargetableObject,
ObjectType.EffectObject,
ObjectType.CountDownObject,
ObjectType.UnknownItem38,
ObjectType.UnknownItem39,
ObjectType.UnknownItem40,
ObjectType.UnknownItem41,
ObjectType.TelepipeLocation,
ObjectType.BGMCollision,
ObjectType.Pioneer2InvisibleTouchplate,
ObjectType.TempleMapDetect,
ObjectType.Firework,
ObjectType.MainRagolTeleporterBattleInNextArea,
ObjectType.Rainbow,
ObjectType.FloatingBlueLight,
ObjectType.PopupTrapNoTech,
ObjectType.Poison,
ObjectType.EnemyTypeBoxYellow,
ObjectType.EnemyTypeBoxBlue,
ObjectType.EmptyTypeBoxBlue,
ObjectType.FloatingSoul,
ObjectType.Butterfly,
ObjectType.UnknownItem400,
ObjectType.CcaAreaTeleporter,
ObjectType.UnknownItem523,
ObjectType.WhiteBird,
ObjectType.OrangeBird,
ObjectType.UnknownItem529,
ObjectType.UnknownItem530,
ObjectType.Seagull,
ObjectType.UnknownItem576,
ObjectType.WarpInBarbaRayRoom,
ObjectType.UnknownItem672,
ObjectType.InstaWarp,
ObjectType.LabInvisibleObject,
ObjectType.UnknownItem700,
ObjectType.Ep4LightSource,
ObjectType.BreakableBrownRock,
ObjectType.UnknownItem897,
ObjectType.UnknownItem898,
ObjectType.OozingDesertPlant,
ObjectType.UnknownItem901,
ObjectType.UnknownItem903,
ObjectType.UnknownItem904,
ObjectType.UnknownItem905,
ObjectType.UnknownItem906,
ObjectType.DesertPlantHasCollision,
ObjectType.UnknownItem910,
ObjectType.UnknownItem912,
ObjectType.Heat,
ObjectType.TopOfSaintMillionEgg,
ObjectType.UnknownItem961,
-> null
else -> {
type.typeId?.let { typeId ->
"/objects/${typeId}${fullSuffix}.$extension"
}
}
}
}
else -> {
error("$type not supported.")
}
}
}

View File

@ -0,0 +1,20 @@
package world.phantasmal.web.questEditor.loading
import kotlinx.coroutines.Deferred
import world.phantasmal.core.disposable.TrackedDisposable
class LoadingCache<K, V> : TrackedDisposable() {
private val map = mutableMapOf<K, Deferred<V>>()
operator fun set(key: K, value: Deferred<V>) {
map[key] = value
}
fun getOrPut(key: K, defaultValue: () -> Deferred<V>): Deferred<V> =
map.getOrPut(key, defaultValue)
override fun internalDispose() {
map.values.forEach { it.cancel() }
super.internalDispose()
}
}

View File

@ -0,0 +1,3 @@
package world.phantasmal.web.questEditor.models
class AreaModel

View File

@ -0,0 +1,3 @@
package world.phantasmal.web.questEditor.models
class AreaVariantModel

View File

@ -0,0 +1,27 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.fileFormats.quest.EntityType
import world.phantasmal.lib.fileFormats.quest.QuestEntity
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.core.rendering.conversion.vec3ToBabylon
import world.phantasmal.web.externals.babylon.Vector3
abstract class QuestEntityModel<Type : EntityType, Entity : QuestEntity<Type>>(
private val entity: Entity,
) {
private val _position = mutableVal(vec3ToBabylon(entity.position))
private val _worldPosition = mutableVal(_position.value)
val type: Type get() = entity.type
/**
* Section-relative position
*/
val position: Val<Vector3> = _position
/**
* World position
*/
val worldPosition: Val<Vector3> = _worldPosition
}

View File

@ -2,6 +2,8 @@ package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.mutableListVal
import world.phantasmal.observable.value.mutableVal
class QuestModel(
@ -11,18 +13,24 @@ class QuestModel(
shortDescription: String,
longDescription: String,
val episode: Episode,
npcs: MutableList<QuestNpcModel>,
objects: MutableList<QuestObjectModel>,
) {
private val _id = mutableVal(0)
private val _language = mutableVal(0)
private val _name = mutableVal("")
private val _shortDescription = mutableVal("")
private val _longDescription = mutableVal("")
private val _npcs = mutableListVal(npcs)
private val _objects = mutableListVal(objects)
val id: Val<Int> = _id
val language: Val<Int> = _language
val name: Val<String> = _name
val shortDescription: Val<String> = _shortDescription
val longDescription: Val<String> = _longDescription
val npcs: ListVal<QuestNpcModel> = _npcs
val objects: ListVal<QuestObjectModel> = _objects
init {
setId(id)

View File

@ -0,0 +1,12 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.fileFormats.quest.NpcType
import world.phantasmal.lib.fileFormats.quest.QuestNpc
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
class QuestNpcModel(npc: QuestNpc, wave: WaveModel?) : QuestEntityModel<NpcType, QuestNpc>(npc) {
private val _wave = mutableVal(wave)
val wave: Val<WaveModel?> = _wave
}

View File

@ -0,0 +1,6 @@
package world.phantasmal.web.questEditor.models
import world.phantasmal.lib.fileFormats.quest.ObjectType
import world.phantasmal.lib.fileFormats.quest.QuestObject
class QuestObjectModel(obj: QuestObject) : QuestEntityModel<ObjectType, QuestObject>(obj)

View File

@ -0,0 +1,3 @@
package world.phantasmal.web.questEditor.models
class WaveModel

View File

@ -0,0 +1,136 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mu.KotlinLogging
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.observable.value.Val
import world.phantasmal.web.externals.babylon.AbstractMesh
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.web.questEditor.rendering.conversion.EntityMetadata
private val logger = KotlinLogging.logger {}
private class LoadedEntity(val entity: QuestEntityModel<*, *>, val disposer: Disposer)
class EntityMeshManager(
private val scope: CoroutineScope,
private val selectedWave: Val<WaveModel?>,
private val renderer: QuestRenderer,
private val entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() {
private val queue: MutableList<QuestEntityModel<*, *>> = mutableListOf()
private val loadedEntities: MutableList<LoadedEntity> = mutableListOf()
private var loading = false
override fun internalDispose() {
removeAll()
super.internalDispose()
}
fun add(entities: List<QuestEntityModel<*, *>>) {
queue.addAll(entities)
if (!loading) {
scope.launch {
try {
loading = true
while (queue.isNotEmpty()) {
val entity = queue.first()
try {
load(entity)
} catch (e: Error) {
logger.error(e) {
"Couldn't load model for entity of type ${entity.type}."
}
queue.remove(entity)
}
}
} finally {
loading = false
}
}
}
}
fun remove(entities: List<QuestEntityModel<*, *>>) {
for (entity in entities) {
queue.remove(entity)
val loadedIndex = loadedEntities.indexOfFirst { it.entity == entity }
if (loadedIndex != -1) {
val loaded = loadedEntities.removeAt(loadedIndex)
renderer.removeEntityMesh(loaded.entity)
loaded.disposer.dispose()
}
}
}
fun removeAll() {
for (loaded in loadedEntities) {
loaded.disposer.dispose()
}
loadedEntities.clear()
queue.clear()
}
private suspend fun load(entity: QuestEntityModel<*, *>) {
// TODO
val mesh = entityAssetLoader.loadMesh(entity.type, model = null)
// Only add an instance of this mesh if the entity is still in the queue at this point.
if (queue.remove(entity)) {
val instance = mesh.createInstance(entity.type.uniqueName)
instance.metadata = EntityMetadata(entity)
instance.position = entity.worldPosition.value
updateEntityMesh(entity, instance)
}
}
private fun updateEntityMesh(entity: QuestEntityModel<*, *>, mesh: AbstractMesh) {
renderer.addEntityMesh(mesh)
val disposer = Disposer(
entity.worldPosition.observe { (pos) ->
mesh.position = pos
renderer.scheduleRender()
},
// TODO: Rotation.
// entity.worldRotation.observe { (value) ->
// mesh.rotation.copy(value)
// renderer.schedule_render()
// },
// TODO: Model.
// entity.model.observe {
// remove(listOf(entity))
// add(listOf(entity))
// },
)
if (entity is QuestNpcModel) {
disposer.add(
selectedWave
.map(entity.wave) { selectedWave, entityWave ->
selectedWave == null || selectedWave == entityWave
}
.observe(callNow = true) { (visible) ->
mesh.setEnabled(visible)
renderer.scheduleRender()
},
)
}
loadedEntities.add(LoadedEntity(entity, disposer))
}
}

View File

@ -0,0 +1,46 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.listVal
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.*
class QuestEditorMeshManager(
scope: CoroutineScope,
private val currentQuest: Val<QuestModel?>,
private val currentArea: Val<AreaModel?>,
selectedWave: Val<WaveModel?>,
renderer: QuestRenderer,
entityAssetLoader: EntityAssetLoader,
) : QuestMeshManager(scope, selectedWave, renderer, entityAssetLoader) {
init {
disposer.addAll(
currentQuest.observe { areaVariantChanged() },
currentArea.observe { areaVariantChanged() },
)
}
override fun getAreaVariantDetails(): AreaVariantDetails {
val quest = currentQuest.value
val area = currentArea.value
val areaVariant: AreaVariantModel?
val npcs: ListVal<QuestNpcModel>
val objects: ListVal<QuestObjectModel>
if (quest != null /*&& area != null*/) {
// TODO: Set areaVariant.
areaVariant = null
npcs = quest.npcs // TODO: Filter NPCs.
objects = listVal() // TODO: Filter objects.
} else {
areaVariant = null
npcs = listVal()
objects = listVal()
}
return AreaVariantDetails(quest?.episode, areaVariant, npcs, objects)
}
}

View File

@ -0,0 +1,78 @@
package world.phantasmal.web.questEditor.rendering
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.TrackedDisposable
import world.phantasmal.lib.fileFormats.quest.Episode
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.list.ListVal
import world.phantasmal.observable.value.list.ListValChangeEvent
import world.phantasmal.web.questEditor.loading.EntityAssetLoader
import world.phantasmal.web.questEditor.models.AreaVariantModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
import world.phantasmal.web.questEditor.models.WaveModel
/**
* Loads the necessary area and entity 3D models into [QuestRenderer].
*/
abstract class QuestMeshManager protected constructor(
scope: CoroutineScope,
selectedWave: Val<WaveModel?>,
private val renderer: QuestRenderer,
entityAssetLoader: EntityAssetLoader,
) : TrackedDisposable() {
protected val disposer = Disposer()
private val areaDisposer = disposer.add(Disposer())
private val npcMeshManager = disposer.add(
EntityMeshManager(scope, selectedWave, renderer, entityAssetLoader)
)
private val objectMeshManager = disposer.add(
EntityMeshManager(scope, selectedWave, renderer, entityAssetLoader)
)
protected abstract fun getAreaVariantDetails(): AreaVariantDetails
protected fun areaVariantChanged() {
val details = getAreaVariantDetails()
// TODO: Load area mesh.
areaDisposer.disposeAll()
npcMeshManager.removeAll()
renderer.resetEntityMeshes()
// Load entity meshes.
areaDisposer.addAll(
details.npcs.observeList(callNow = true, ::npcsChanged),
details.objects.observeList(callNow = true, ::objectsChanged),
)
}
override fun internalDispose() {
disposer.dispose()
super.internalDispose()
}
private fun npcsChanged(change: ListValChangeEvent<QuestNpcModel>) {
if (change is ListValChangeEvent.Change) {
npcMeshManager.remove(change.removed)
npcMeshManager.add(change.inserted)
}
}
private fun objectsChanged(change: ListValChangeEvent<QuestObjectModel>) {
if (change is ListValChangeEvent.Change) {
objectMeshManager.remove(change.removed)
objectMeshManager.add(change.inserted)
}
}
}
class AreaVariantDetails(
val episode: Episode?,
val areaVariant: AreaVariantModel?,
val npcs: ListVal<QuestNpcModel>,
val objects: ListVal<QuestObjectModel>,
)

View File

@ -1,28 +1,83 @@
package world.phantasmal.web.questEditor.rendering
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.webui.newJsObject
import world.phantasmal.web.core.rendering.Renderer
import world.phantasmal.web.externals.*
import world.phantasmal.web.externals.babylon.*
import world.phantasmal.web.questEditor.models.QuestEntityModel
import world.phantasmal.web.questEditor.rendering.conversion.EntityMetadata
import kotlin.math.PI
class QuestRenderer(
canvas: HTMLCanvasElement,
createEngine: (HTMLCanvasElement) -> Engine,
) : Renderer(canvas, createEngine) {
private val camera = ArcRotateCamera("Camera", PI / 2, PI / 2, 2.0, Vector3.Zero(), scene)
engine: Engine,
createMeshManager: (QuestRenderer, Scene) -> QuestMeshManager,
) : Renderer(canvas, engine) {
private val meshManager = createMeshManager(this, scene)
private var entityMeshes = TransformNode("Entities", scene)
private val entityToMesh = mutableMapOf<QuestEntityModel<*, *>, AbstractMesh>()
private val camera = ArcRotateCamera("Camera", 0.0, PI / 6, 500.0, Vector3.Zero(), scene)
private val light = HemisphericLight("Light", Vector3(1.0, 1.0, 0.0), scene)
private val cylinder =
MeshBuilder.CreateCylinder("Cylinder", newJsObject { diameter = 1.0 }, scene)
init {
camera.attachControl(canvas, noPreventDefault = true)
with(camera) {
attachControl(
canvas,
noPreventDefault = false,
useCtrlForPanning = false,
panningMouseButton = 0
)
inertia = 0.0
angularSensibilityX = 200.0
angularSensibilityY = 200.0
panningInertia = 0.0
panningSensibility = 3.0
panningAxis = Vector3(1.0, 0.0, 1.0)
pinchDeltaPercentage = 0.1
wheelDeltaPercentage = 0.1
}
}
override fun internalDispose() {
meshManager.dispose()
entityMeshes.dispose()
entityToMesh.clear()
camera.dispose()
light.dispose()
cylinder.dispose()
super.internalDispose()
}
fun resetEntityMeshes() {
entityMeshes.dispose(false)
entityToMesh.clear()
entityMeshes = TransformNode("Entities", scene)
scheduleRender()
}
fun addEntityMesh(mesh: AbstractMesh) {
val entity = (mesh.metadata as EntityMetadata).entity
mesh.parent = entityMeshes
entityToMesh[entity]?.let { prevMesh ->
prevMesh.parent = null
prevMesh.dispose()
}
entityToMesh[entity] = mesh
// TODO: Mark selected entity.
// if (entity === this.selected_entity) {
// this.mark_selected(model)
// }
this.scheduleRender()
}
fun removeEntityMesh(entity: QuestEntityModel<*, *>) {
entityToMesh.remove(entity)?.let { mesh ->
mesh.parent = null
mesh.dispose()
this.scheduleRender()
}
}
}

View File

@ -0,0 +1,5 @@
package world.phantasmal.web.questEditor.rendering.conversion
import world.phantasmal.web.questEditor.models.QuestEntityModel
class EntityMetadata(val entity: QuestEntityModel<*, *>)

View File

@ -2,6 +2,8 @@ package world.phantasmal.web.questEditor.stores
import world.phantasmal.lib.fileFormats.quest.Quest
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.models.QuestNpcModel
import world.phantasmal.web.questEditor.models.QuestObjectModel
fun convertQuestToModel(quest: Quest): QuestModel {
return QuestModel(
@ -11,5 +13,8 @@ fun convertQuestToModel(quest: Quest): QuestModel {
quest.shortDescription,
quest.longDescription,
quest.episode,
// TODO: Add WaveModel to QuestNpcModel
quest.npcs.mapTo(mutableListOf()) { QuestNpcModel(it, null) },
quest.objects.mapTo(mutableListOf()) { QuestObjectModel(it) }
)
}

View File

@ -3,13 +3,19 @@ package world.phantasmal.web.questEditor.stores
import kotlinx.coroutines.CoroutineScope
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.web.questEditor.models.AreaModel
import world.phantasmal.web.questEditor.models.QuestModel
import world.phantasmal.web.questEditor.models.WaveModel
import world.phantasmal.webui.stores.Store
class QuestEditorStore(scope: CoroutineScope) : Store(scope) {
private val _currentQuest = mutableVal<QuestModel?>(null)
private val _currentArea = mutableVal<AreaModel?>(null)
private val _selectedWave = mutableVal<WaveModel?>(null)
val currentQuest: Val<QuestModel?> = _currentQuest
val currentArea: Val<AreaModel?> = _currentArea
val selectedWave: Val<WaveModel?> = _selectedWave
// TODO: Take into account whether we're debugging or not.
val questEditingDisabled: Val<Boolean> = currentQuest.map { it == null }

View File

@ -2,10 +2,9 @@ package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.questEditor.rendering.QuestRenderer
class QuestEditorRendererWidget(
scope: CoroutineScope,
createEngine: (HTMLCanvasElement) -> Engine,
) : QuestRendererWidget(scope, createEngine) {
}
createRenderer: (HTMLCanvasElement) -> QuestRenderer,
) : QuestRendererWidget(scope, createRenderer)

View File

@ -3,6 +3,7 @@ package world.phantasmal.web.questEditor.widgets
import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.Node
import world.phantasmal.web.questEditor.controllers.QuestEditorToolbarController
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.FileButton
import world.phantasmal.webui.widgets.Toolbar
@ -22,6 +23,7 @@ class QuestEditorToolbar(
FileButton(
scope,
text = "Open file...",
iconLeft = Icon.File,
accept = ".bin, .dat, .qst",
multiple = true,
filesSelected = ctrl::openFiles

View File

@ -17,7 +17,7 @@ private class TestWidget(scope: CoroutineScope) : Widget(scope) {
}
}
open class QuestEditorWidget(
class QuestEditorWidget(
scope: CoroutineScope,
private val toolbar: Widget,
private val createQuestInfoWidget: (CoroutineScope) -> Widget,

View File

@ -4,19 +4,20 @@ import kotlinx.coroutines.CoroutineScope
import org.w3c.dom.HTMLCanvasElement
import org.w3c.dom.Node
import world.phantasmal.web.core.widgets.RendererWidget
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.questEditor.rendering.QuestRenderer
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.widgets.Widget
abstract class QuestRendererWidget(
scope: CoroutineScope,
private val createEngine: (HTMLCanvasElement) -> Engine,
private val createRenderer: (HTMLCanvasElement) -> QuestRenderer,
) : Widget(scope) {
override fun Node.createElement() =
div {
className = "pw-quest-editor-quest-renderer"
tabIndex = -1
addChild(RendererWidget(scope, createEngine))
addChild(RendererWidget(scope, createRenderer))
}
companion object {

View File

@ -9,9 +9,9 @@ import world.phantasmal.core.disposable.Disposer
import world.phantasmal.core.disposable.disposable
import world.phantasmal.core.disposable.use
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.HttpAssetLoader
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.externals.babylon.Engine
import world.phantasmal.web.test.TestApplicationUrl
import kotlin.test.Test
@ -35,7 +35,7 @@ class ApplicationTests : TestSuite() {
Application(
scope,
rootElement = document.body!!,
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
assetLoader = AssetLoader(basePath = "", httpClient),
applicationUrl = appUrl,
createEngine = { Engine(it) }
)

View File

@ -6,7 +6,7 @@ import io.ktor.client.features.json.serializer.*
import kotlinx.coroutines.cancel
import world.phantasmal.core.disposable.disposable
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.HttpAssetLoader
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.test.TestApplicationUrl
@ -29,7 +29,7 @@ class HuntOptimizerTests : TestSuite() {
disposer.add(
HuntOptimizer(
scope,
assetLoader = HttpAssetLoader(httpClient, basePath = ""),
AssetLoader(basePath = "", httpClient),
uiStore
)
)

View File

@ -6,10 +6,8 @@ import io.ktor.client.features.json.serializer.*
import kotlinx.coroutines.cancel
import world.phantasmal.core.disposable.disposable
import world.phantasmal.testUtils.TestSuite
import world.phantasmal.web.core.stores.PwTool
import world.phantasmal.web.core.stores.UiStore
import world.phantasmal.web.externals.Engine
import world.phantasmal.web.test.TestApplicationUrl
import world.phantasmal.web.core.loading.AssetLoader
import world.phantasmal.web.externals.babylon.Engine
import kotlin.test.Test
class QuestEditorTests : TestSuite() {
@ -24,12 +22,10 @@ class QuestEditorTests : TestSuite() {
}
disposer.add(disposable { httpClient.cancel() })
val uiStore = disposer.add(UiStore(scope, TestApplicationUrl("/${PwTool.QuestEditor}")))
disposer.add(
QuestEditor(
scope,
uiStore,
AssetLoader(basePath = "", httpClient),
createEngine = { Engine(it) }
)
)

View File

@ -14,6 +14,7 @@ dependencies {
api(project(":core"))
api(project(":observable"))
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation(npm("@fortawesome/fontawesome-free", "^5.13.1"))
testImplementation(kotlin("test-js"))
testImplementation(project(":test-utils"))

View File

@ -1,4 +1,4 @@
package world.phantasmal.webui
fun <T> newJsObject(block: T.() -> Unit): T =
fun <T> obj(block: T.() -> Unit): T =
js("{}").unsafeCast<T>().apply(block)

View File

@ -5,6 +5,7 @@ import kotlinx.dom.appendText
import org.w3c.dom.AddEventListenerOptions
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.Node
import org.w3c.dom.events.Event
import org.w3c.dom.events.EventTarget
import world.phantasmal.core.disposable.Disposable
@ -33,3 +34,51 @@ fun HTMLElement.root(): HTMLElement {
id = "pw-root"
return this
}
enum class Icon {
ArrowDown,
Eye,
File,
GitHub,
LevelDown,
LevelUp,
LongArrowRight,
NewFile,
Play,
Plus,
Redo,
Remove,
Save,
SquareArrowRight,
Stop,
TriangleDown,
TriangleUp,
Undo,
}
fun Node.icon(icon: Icon): HTMLElement {
val iconStr = when (icon) {
Icon.ArrowDown -> "fas fa-arrow-down"
Icon.Eye -> "far fa-eye"
Icon.File -> "fas fa-file"
Icon.GitHub -> "fab fa-github"
Icon.LevelDown -> "fas fa-level-down-alt"
Icon.LevelUp -> "fas fa-level-up-alt"
Icon.LongArrowRight -> "fas fa-long-arrow-alt-right"
Icon.NewFile -> "fas fa-file-medical"
Icon.Play -> "fas fa-play"
Icon.Plus -> "fas fa-plus"
Icon.Redo -> "fas fa-redo"
Icon.Remove -> "fas fa-trash-alt"
Icon.Save -> "fas fa-save"
Icon.Stop -> "fas fa-stop"
Icon.SquareArrowRight -> "far fa-caret-square-right"
Icon.TriangleDown -> "fas fa-caret-down"
Icon.TriangleUp -> "fas fa-caret-up"
Icon.Undo -> "fas fa-undo"
}
// Wrap the span in another span, because Font Awesome will replace the inner element. This way
// the returned element will stay valid.
return span { span { className = iconStr } }
}

View File

@ -3,9 +3,6 @@ package world.phantasmal.webui.dom
import kotlinx.browser.document
import org.w3c.dom.*
fun Node.a(block: HTMLAnchorElement.() -> Unit = {}): HTMLAnchorElement =
appendHtmlEl("A", block)
fun Node.button(block: HTMLButtonElement.() -> Unit = {}): HTMLButtonElement =
appendHtmlEl("BUTTON", block)

View File

@ -6,7 +6,9 @@ import org.w3c.dom.events.KeyboardEvent
import org.w3c.dom.events.MouseEvent
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.button
import world.phantasmal.webui.dom.icon
import world.phantasmal.webui.dom.span
open class Button(
@ -15,6 +17,8 @@ open class Button(
disabled: Val<Boolean> = falseVal(),
private val text: String? = null,
private val textVal: Val<String>? = null,
private val iconLeft: Icon? = null,
private val iconRight: Icon? = null,
private val onMouseDown: ((MouseEvent) -> Unit)? = null,
private val onMouseUp: ((MouseEvent) -> Unit)? = null,
private val onClick: ((MouseEvent) -> Unit)? = null,
@ -35,6 +39,13 @@ open class Button(
span {
className = "pw-button-inner"
iconLeft?.let {
span {
className = "pw-button-left"
icon(iconLeft)
}
}
span {
className = "pw-button-center"
@ -49,6 +60,13 @@ open class Button(
hidden = true
}
}
iconRight?.let {
span {
className = "pw-button-right"
icon(iconRight)
}
}
}
}

View File

@ -5,6 +5,7 @@ import org.w3c.dom.HTMLElement
import org.w3c.files.File
import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.openFiles
class FileButton(
@ -13,10 +14,12 @@ class FileButton(
disabled: Val<Boolean> = falseVal(),
text: String? = null,
textVal: Val<String>? = null,
iconLeft: Icon? = null,
iconRight: Icon? = null,
private val accept: String = "",
private val multiple: Boolean = false,
private val filesSelected: ((List<File>) -> Unit)? = null,
) : Button(scope, hidden, disabled, text, textVal) {
) : Button(scope, hidden, disabled, text, textVal, iconLeft, iconRight) {
override fun interceptElement(element: HTMLElement) {
element.classList.add("pw-file-button")

View File

@ -11,7 +11,7 @@ import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.value
import world.phantasmal.webui.dom.disposableListener
import world.phantasmal.webui.dom.div
import world.phantasmal.webui.newJsObject
import world.phantasmal.webui.obj
class Menu<T : Any>(
scope: CoroutineScope,
@ -173,7 +173,7 @@ class Menu<T : Any>(
highlightedElement?.let {
highlightedIndex = index
it.classList.add("pw-menu-highlighted")
it.scrollIntoView(newJsObject { block = "nearest" })
it.scrollIntoView(obj { block = "nearest" })
}
}

View File

@ -8,6 +8,7 @@ import world.phantasmal.observable.value.Val
import world.phantasmal.observable.value.falseVal
import world.phantasmal.observable.value.mutableVal
import world.phantasmal.observable.value.value
import world.phantasmal.webui.dom.Icon
import world.phantasmal.webui.dom.div
class Select<T : Any>(
@ -51,6 +52,7 @@ class Select<T : Any>(
scope,
disabled = disabled,
textVal = buttonText,
iconRight = Icon.TriangleDown,
onMouseDown = ::onButtonMouseDown,
onMouseUp = { onButtonMouseUp() },
onKeyDown = ::onButtonKeyDown,

View File

@ -228,6 +228,13 @@ abstract class Widget(
el
}
init {
js("require('@fortawesome/fontawesome-free/js/fontawesome');")
js("require('@fortawesome/fontawesome-free/js/solid');")
js("require('@fortawesome/fontawesome-free/js/regular');")
js("require('@fortawesome/fontawesome-free/js/brands');")
}
protected fun style(style: String) {
STYLE_EL.append(style)
}