Skip to content

Instantly share code, notes, and snippets.

@MrPowerGamerBR
Last active March 13, 2025 04:25
Show Gist options
  • Save MrPowerGamerBR/a343d15765949416c1fcef94cec43696 to your computer and use it in GitHub Desktop.
Save MrPowerGamerBR/a343d15765949416c1fcef94cec43696 to your computer and use it in GitHub Desktop.
SneakySims' The Sims 1 Skin Renderer (frontend & backend)
package net.sneakysims.website.routes
import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.response.*
import io.ktor.server.util.*
import kotlinx.serialization.json.Json
import net.perfectdreams.sequins.ktor.BaseRoute
import net.sneakysims.sneakylib.cmx.CMX
import net.sneakysims.sneakylib.skn.SKN
import net.sneakysims.website.SneakySimsWebsite
import net.sneakysims.website.customcontent.ContentDescriptionCleaner
import net.sneakysims.website.skinrenderer.SkeletonType
import net.sneakysims.website.skinrenderer.SkinColorType
import net.sneakysims.website.skinrenderer.SkinRendererWardrobe
import net.sneakysims.website.tables.CustomContentVersionDownloads
import net.sneakysims.website.tables.CustomContentCollections
import net.sneakysims.website.tables.CustomContentTags
import net.sneakysims.website.tables.CustomContentVersions
import net.sneakysims.website.utils.TagWrapper
import net.sneakysims.website.utils.VirtualFileSystem
import net.sneakysims.website.utils.exposed.containsAllLong
import net.sneakysims.website.views.CustomContentEntryView
import net.sneakysims.website.views.CustomContentEntryView.CCFile
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.selectAll
import org.jsoup.Jsoup
import java.io.File
class CustomContentEntryRoute(val m: SneakySimsWebsite) : BaseRoute(Regex("/mods/(?<slug>[0-9]+-[A-Za-z0-9-]+)")) {
override suspend fun onRequest(call: ApplicationCall) {
val slug = call.parameters.getOrFail("slug")
val collectionId = slug.substringBefore("-").toLong()
data class Result(
val collection: ResultRow,
val ccTags: List<ResultRow>,
val ccUpload: ResultRow,
val tagsCount: Map<String, Long>,
val downloads: Long
)
val (ccCollection, ccTags, ccUpload, tagsCount, downloads) = m.custard.transaction {
val collection = CustomContentCollections.selectAll()
.where { CustomContentCollections.id eq collectionId and (CustomContentCollections.published eq true) }
.first()
val tags = CustomContentTags.selectAll()
.where {
CustomContentTags.id inList collection[CustomContentCollections.tags]
}
.toList()
val upload = CustomContentVersions.selectAll()
.where {
CustomContentVersions.collection eq collection[CustomContentCollections.id]
}
.orderBy(CustomContentVersions.uploadedAt, SortOrder.DESC)
.first()
val tagsCount = mutableMapOf<String, Long>()
// This is awful, but I think that all options would involve some nasty queries
for (tag in tags) {
val tagCount = CustomContentCollections.selectAll()
.where { CustomContentCollections.published eq true and (containsAllLong(CustomContentCollections.tags, listOf(tag[CustomContentTags.id].value))) }
.count()
tagsCount[tag[CustomContentTags.name]] = tagCount
}
val downloads = CustomContentVersionDownloads.selectAll()
.where {
CustomContentVersionDownloads.version eq upload[CustomContentVersions.id]
}
.count()
return@transaction Result(collection, tags, upload, tagsCount, downloads)
}
val customContentSlug = "${ccCollection[CustomContentCollections.id]}-${ccCollection[CustomContentCollections.slug]}"
if (slug != customContentSlug) {
call.respondRedirect("/mods/$customContentSlug", permanent = true)
return
}
val headSkins = mutableListOf<SkinRendererWardrobe.WardrobeSkin>()
val bodySkins = mutableListOf<SkinRendererWardrobe.WardrobeSkin>()
val images = mutableListOf<String>()
val files = mutableListOf<CCFile>()
val projectFolder = File(m.customContentBaseFolder, ccCollection[CustomContentCollections.projectFolder])
val file = File(projectFolder, ccUpload[CustomContentVersions.file])
val virtualFileSystemCCTemp = VirtualFileSystem(ignoreCase = true)
virtualFileSystemCCTemp.addZIP(file)
val (virtualFileSystemCC, vfsMaxisGame, virtualFileSystem, customContentRemappedFilesTable) = m.theSims1ContentImporter.createSkinVFS(virtualFileSystemCCTemp)
for (file in virtualFileSystem.getAllFiles()) {
if (file.extension == "cmx") {
val cmx = CMX.read(file.readText())
println(cmx)
for (suit in cmx.suits) {
println("Processing suit ${suit.suitName}")
val skeletonTypeCode = suit.suitName.substringBefore("_").lowercase()[5]
val skeletonType = if (skeletonTypeCode == 'a') {
SkeletonType.ADULT
} else if (skeletonTypeCode == 'c') {
SkeletonType.CHILD
} else error("Unsupported Skeleton Type! $skeletonTypeCode")
// This is hard because *technically* if it is a PELVIS or a HEAD, then it is *I think* a new whole ass skin, NOT an accessory
// (At least, that's what I noticed when testing out, cyy5fclgt_chirno.bmp and cyy5fclgt_chirno2.bmp are two different skins, they all use the same base byy5fcchdlgt_chirno.bmp texture)
// So, here's how we'll handle this thing:
// 1. Attempt to load a PELVIS or HEAD skin as is
// 2. If it is present, THEN we'll attempt to load all accessories related to this suit
// This is hard because we can't just filter and hope for the best, we NEED to get the matching skin for WHATEVER the frick we think it is correct
val rootBone = if (suit.suitName.lowercase().startsWith("c"))
"HEAD"
else
"PELVIS"
val rootSkin = suit.skins.first { it.boneName == rootBone }
// The skin name controls which .skn it is used
val skinName = rootSkin.skinName
println("Processing skin ${suit.suitName} -> $skinName")
val sknFile = virtualFileSystem.file("$skinName.skn")
println("Loading from $sknFile")
val skn = SKN.read(sknFile.readText())
// This sucks, some skins have a non "x" bitmap file BUT the game DOES load any matching bmp too
// But it seems to only load if it is a non-accessory (like a head/body?)
// So, to handle this, we'll only load matching bmps if it is NOT a head/body
// This is hard because *technically* if it is a PELVIS or a HEAD, then it is *I think* a new whole ass skin, NOT an accessory
for (file in virtualFileSystemCC.getAllFiles()) {
if (file.extension == "bmp" || file.extension == "tga") {
val prefix = if (suit.suitName.startsWith("c", true)) {
suit.suitName.substring(0..5).lowercase()
} else {
suit.suitName.substring(0..8).lowercase()
}
println("Prefix: $prefix")
if (file.name.lowercase().startsWith(prefix)) {
val bmpPrefix = file.name.substringBefore("_").lowercase()
val bmpSuffixWithoutExtension = file.name.substringAfter("_").substringBeforeLast(".")
val skinColorType = if (bmpPrefix.endsWith("lgt")) {
SkinColorType.LIGHT
} else if (bmpPrefix.endsWith("med")) {
SkinColorType.MEDIUM
} else if (bmpPrefix.endsWith("drk")) {
SkinColorType.DARK
} else error("Can't determine the skin color type! $bmpPrefix")
println("SKN ${skinName} -> ${file.name}")
// Does the skin have custom hand textures?
var handBmpPath: String? = null
val bmpHandFile = virtualFileSystemCC.fileOrNull("HUAO${skinColorType.code}_$bmpSuffixWithoutExtension.bmp")
val tgaHandFile = virtualFileSystemCC.fileOrNull("HUAO${skinColorType.code}_$bmpSuffixWithoutExtension.tga")
if (bmpHandFile != null)
handBmpPath = bmpHandFile.name
if (tgaHandFile != null)
handBmpPath = tgaHandFile.name
val accessories = mutableListOf<SkinRendererWardrobe.SkinAccessory>()
accessories.add(
SkinRendererWardrobe.SkinAccessory(
skinColorType,
// TODO: This is wrong and it is hard to fix because some files may be loaded from different sources
// (Example: Some skns are from the base-game folder)
if (vfsMaxisGame.hasFile(sknFile.name))
"/files/maxis/base-game/ExpansionShared/SkinsBuy/${sknFile.name}"
else
"/files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[sknFile.name]}",
"/files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[file.name]}",
handBmpPath?.let { "/files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[it]}" }
)
)
// Now that we found a specific variant for this skin, NOW we will attempt to parse ANY accessory of the skin!
for (accessorySknCMX in suit.skins.filter { it != rootSkin }) {
val acessorySknFile = virtualFileSystemCC.file(accessorySknCMX.skinName + ".skn")
val accessorySkn = SKN.read(acessorySknFile.readText())
// Probably an accessory, attempt to load from the bitmap file!
//
// In "vanilla" The Sims 1 (as in, Complete Collection) some SKNs do have set bitmaps, but the file does not exist! Why is that?
// I don't know *what* causes this, and if it is intentional.
//
// Because we don't know, we will only attempt to load skins that have matching bitmap files in the ccPath
//
// Also, we will ALWAYS pretend that the accessory bitmap file is ALWAYS present
val file = virtualFileSystemCC.file(accessorySkn.bitmapFileName + ".bmp") // TODO: This can be a TARGA file!
println("SKN ${skinName} -> ${file.name} (special)")
// TODO: Maybe it DOES care about skin color, but I think it always inherit from the parent
accessories.add(
SkinRendererWardrobe.SkinAccessory(
skinColorType,
"/files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[acessorySknFile.name]}",
"/files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[file.name]}",
// TODO: We need to refactor this code to avoid using this
null
)
)
}
println("Parsed ${suit.suitName} (with ${suit.skins.size} skins) with the following accessories: $accessories")
// And finally, let's add the accessory list to the head/body skins list!
if (suit.suitName.startsWith("c", true)) {
headSkins.add(
SkinRendererWardrobe.WardrobeSkin(
skeletonType,
accessories
)
)
} else {
bodySkins.add(
SkinRendererWardrobe.WardrobeSkin(
skeletonType,
accessories
)
)
}
}
}
}
}
}
}
for (file in virtualFileSystemCC.getAllFiles()) {
files.add(CCFile("files/${ccCollection[CustomContentCollections.projectFolder]}/${ccUpload[CustomContentVersions.file]}/${customContentRemappedFilesTable[file.name]}", customContentRemappedFilesTable[file.name]!!))
}
val tagWrappers = ccTags.map {
TagWrapper(
it[CustomContentTags.name],
it[CustomContentTags.fancyName],
it[CustomContentTags.iconUrl],
it[CustomContentTags.type]
)
}
val creatorTagWrapper = ccTags.first { it[CustomContentTags.id] == ccCollection[CustomContentCollections.creator] }
.let {
TagWrapper(
it[CustomContentTags.name],
it[CustomContentTags.fancyName],
it[CustomContentTags.iconUrl],
it[CustomContentTags.type]
)
}
var descriptionHtml = ccCollection[CustomContentCollections.description]?.let {
val parser = Parser.builder().build()
val document = parser.parse(it)
val renderer = HtmlRenderer.builder().softbreak("<br/>").build()
renderer.render(document)
}
var imagePreviewUrl: String? = null
if (descriptionHtml != null) {
val document = Jsoup.parse(descriptionHtml)
val h1 = document.select("h1").toList()
val h2 = document.select("h2").toList()
val h3 = document.select("h3").toList()
val h4 = document.select("h4").toList()
val h5 = document.select("h5").toList()
h1.forEach { it.tagName("h2") }
h2.forEach { it.tagName("h3") }
h3.forEach { it.tagName("h4") }
h4.forEach { it.tagName("h5") }
h5.forEach { it.tagName("h6") }
val img = document.select("img").toList()
img.forEach {
it.attr("src", "/files/" + ccCollection[CustomContentCollections.projectFolder] + "/" + it.attr("src"))
}
imagePreviewUrl = img.firstOrNull()?.attr("src")
ContentDescriptionCleaner.clean(document)
document.select("ts1-skin-renderer").forEach {
val skins = it.attr("data-skins").ifEmpty { null }?.split(",")
it.replaceWith(
document.createElement("div").apply {
this.attr("data-component-mounter", "skin-renderer")
// How it seems to work:
// A CMX file connects the Mesh to the game, you can't have a .skn and a .bmp as is
// The CMX is who controls the (universe) I mean... it controls the type of the clothes and stuff like that (based on the name)
// The CMX declares which .skn to use, and the BMP name MUST match the name of the cmx
if (skins != null) {
val filteredHeadSkins = mutableListOf<SkinRendererWardrobe.WardrobeSkin>()
val filteredBodySkins = mutableListOf<SkinRendererWardrobe.WardrobeSkin>()
for (skin in headSkins) {
val accessories = mutableListOf<SkinRendererWardrobe.SkinAccessory>()
for (accessory in skin.accessories) {
if (accessory.bmpPath.substringAfterLast(".zip/") in skins) {
accessories.add(accessory)
}
}
if (accessories.isNotEmpty()) {
filteredHeadSkins.add(
SkinRendererWardrobe.WardrobeSkin(
skin.skeletonType,
accessories
)
)
}
}
for (skin in bodySkins) {
val accessories = mutableListOf<SkinRendererWardrobe.SkinAccessory>()
for (accessory in skin.accessories) {
if (accessory.bmpPath.substringAfterLast(".zip/") in skins) {
accessories.add(accessory)
}
}
if (accessories.isNotEmpty()) {
filteredBodySkins.add(
SkinRendererWardrobe.WardrobeSkin(
skin.skeletonType,
accessories
)
)
}
}
this.attr(
"data-skin-renderer-wardrobe",
Json.encodeToString(
SkinRendererWardrobe(
filteredHeadSkins,
filteredBodySkins
)
)
)
} else {
// If null, render all available skins
this.attr(
"data-skin-renderer-wardrobe",
Json.encodeToString(
SkinRendererWardrobe(
headSkins,
bodySkins
)
)
)
}
}
)
}
descriptionHtml = document.body().html()
}
val view = CustomContentEntryView(
customContentSlug,
ccCollection[CustomContentCollections.title],
ccCollection[CustomContentCollections.addedAt],
"${creatorTagWrapper.name}-${ccUpload[CustomContentVersions.file].split("/").last()}",
ccUpload[CustomContentVersions.file],
headSkins,
bodySkins,
images,
tagWrappers,
tagsCount,
files,
creatorTagWrapper,
descriptionHtml,
ccCollection[CustomContentCollections.websiteSourceUrl],
ccCollection[CustomContentCollections.addedAt],
ccUpload[CustomContentVersions.contentCreatedAt],
downloads,
imagePreviewUrl,
ccCollection[CustomContentCollections.useWebsiteSourceUrlAsDownload],
)
call.respondHtml {
apply(view.generateHTML())
}
}
}
package net.sneakysims.website.skinrenderer
enum class SkeletonType {
ADULT,
CHILD
}
package net.sneakysims.website.skinrenderer
enum class SkinColorType(val code: String) {
LIGHT("lgt"),
MEDIUM("med"),
DARK("drk")
}
package net.sneakysims.website.frontend.components
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import net.perfectdreams.harmony.math.Vector3f
import net.perfectdreams.slippyimage.SlippyImage
import net.perfectdreams.slippyimage.toImageData
import net.sneakysims.sneakylib.cmx.CMX
import net.sneakysims.sneakylib.skn.SKN
import net.sneakysims.website.frontend.loadImage
import net.sneakysims.website.frontend.plainStyle
import net.sneakysims.website.frontend.reactcomponents.TSOBodySkinSelector
import net.sneakysims.website.frontend.reactcomponents.TSOHeadSkinSelector
import net.sneakysims.website.frontend.skinrenderer.SkinRendererResources
import net.sneakysims.website.frontend.skinrenderer.TheSims1SkinRenderer
import net.sneakysims.website.skinrenderer.SkeletonType
import net.sneakysims.website.skinrenderer.SkinColorType
import net.sneakysims.website.skinrenderer.SkinRendererWardrobe
import react.*
import react.dom.client.createRoot
import react.dom.html.ReactHTML.canvas
import react.dom.html.ReactHTML.div
import web.animations.requestAnimationFrame
import web.cssom.ClassName
import web.device.devicePixelRatio
import web.events.addEventHandler
import web.gl.WebGL2RenderingContext
import web.gl.WebGLTexture
import web.html.HTMLCanvasElement
import web.html.HTMLElement
import web.uievents.MouseEvent
class SkinRendererComponentMounter : ComponentMounter("skin-renderer") {
override fun mount(element: HTMLElement) {
// Attempt to load all skn files
// Character Box CSS
// width: 48px;height: 110px;border: 2px solid #9cdaf8;border-radius: 7px;background-color: #304058;
val wardrobe = element.getAttribute("data-skin-renderer-wardrobe")!!.let {
Json.decodeFromString<SkinRendererWardrobe>(it)
}
val skinRendererResources = SkinRendererResources()
// We sort by skin color because the game always default to light skin color as the default skin color
val sortedHeads = wardrobe.head.sortedWith(
compareBy<SkinRendererWardrobe.WardrobeSkin>(
{ it.accessories.first().skinColorType },
{ it.accessories.first().bmpPath.lowercase() })
)
val sortedBodies = wardrobe.body.sortedWith(
compareBy<SkinRendererWardrobe.WardrobeSkin>(
{ it.accessories.first().skinColorType },
{ it.accessories.first().bmpPath.lowercase() })
)
val skinRendererComponent = FC<Props> {
var activeHeadSkn by useState(sortedHeads.firstOrNull())
var activeBodySkn by useState(sortedBodies.firstOrNull())
var activeHeadSknRef = useRef(activeHeadSkn)
var activeBodySknRef = useRef(activeBodySkn)
useEffect {
activeHeadSknRef.current = activeHeadSkn
activeBodySknRef.current = activeBodySkn
}
val canvasReference = useRef<HTMLCanvasElement>(null)
div {
plainStyle = "display: flex; flex-direction: column; gap: 0.5em;"
div {
className = ClassName("tso-part-selector-wrapper")
for (body in sortedHeads) {
TSOHeadSkinSelector {
this.wardrobeSkin = body
this.selected = activeHeadSkn == body
this.skinRendererResources = skinRendererResources
this.onClick = {
activeHeadSkn = body
}
}
}
}
div {
className = ClassName("tso-part-selector-wrapper")
for (body in sortedBodies) {
TSOBodySkinSelector {
this.wardrobeSkin = body
this.selected = activeBodySkn == body
this.skinRendererResources = skinRendererResources
this.onClick = {
activeBodySkn = body
}
}
}
}
}
// TODO: Canvas?
fun updateCanvas() {
val canvas = canvasReference.current!!
canvas.style.width = "100%"
canvas.style.aspectRatio = "16/9"
val width = canvas.clientWidth
val height = canvas.clientHeight
canvas.width = (width * devicePixelRatio).toInt()
canvas.height = (height * devicePixelRatio).toInt()
val gl = canvas.getContext(WebGL2RenderingContext.ID)!!
gl.clearColor(1f, 0f, 0f, 1f)
gl.clear(WebGL2RenderingContext.DEPTH_BUFFER_BIT + WebGL2RenderingContext.COLOR_BUFFER_BIT)
val renderer = TheSims1SkinRenderer(width, height, gl)
GlobalScope.launch {
println("Loading cmx data")
val adultCmxData = skinRendererResources.loadFile("/assets/mikutest/adult-skeleton.cmx")
.decodeToString()
val childCmxData = skinRendererResources.loadFile("/assets/mikutest/child-skeleton.cmx")
.decodeToString()
val handLeftSkn = skinRendererResources.loadFile("/files/maxis/base-game/hands/xskin-hulo-L_HAND-HANDCL.skn")
.decodeToString()
.let {
SKN.read(it)
}
val handRightSkn = skinRendererResources.loadFile("/files/maxis/base-game/hands/xskin-huro-R_HAND-HANDCR.skn")
.decodeToString()
.let {
SKN.read(it)
}
// Because heads may have different textures for each body, we need to some hacks here
val handsTextures = mutableMapOf<String, WebGLTexture>()
suspend fun loadAndStoreHandTexture(path: String) {
val extension = path.substringAfterLast(".")
if (extension == "tga") {
// Currently we do not support tga textures, so let's fallback to a empty transparent texture
val image = SlippyImage.createEmpty(4, 4)
handsTextures[path] = renderer.resourceManager.loadTexture(image.toImageData()).textureId
return
}
val image = loadImage(path)
val textureId = renderer.resourceManager.loadTexture(image).textureId
handsTextures[path] = textureId
}
loadAndStoreHandTexture("/files/maxis/base-game/GameData/Textures/Textures.far/HUAOlgt.bmp")
loadAndStoreHandTexture("/files/maxis/base-game/GameData/Textures/Textures.far/HUAOmed.bmp")
loadAndStoreHandTexture("/files/maxis/base-game/GameData/Textures/Textures.far/HUAOdrk.bmp")
for (body in sortedBodies) {
for (accessory in body.accessories) {
val handBmpPath = accessory.handBmpPath
if (handBmpPath != null)
loadAndStoreHandTexture(handBmpPath)
}
}
println("cmx: $adultCmxData")
val adultCmx = CMX.read(adultCmxData)
val childCmx = CMX.read(childCmxData)
val adultHandLeftVAO = renderer.createTheSimsSKNModelVAO(gl, handLeftSkn, adultCmx)
val childHandLeftVAO = renderer.createTheSimsSKNModelVAO(gl, handLeftSkn, childCmx)
val adultHandRightVAO = renderer.createTheSimsSKNModelVAO(gl, handRightSkn, adultCmx)
val childHandRightVAO = renderer.createTheSimsSKNModelVAO(gl, handRightSkn, childCmx)
println("Loading skn data")
// Load all the skn and bmp files for this head model
for (headSkin in wardrobe.head) {
for (accessory in headSkin.accessories) {
val sknData = skinRendererResources.loadFile(accessory.sknPath).decodeToString()
val img = loadImage(accessory.bmpPath)
val skn = SKN.read(sknData)
renderer.sknToParsedSKN[accessory.sknPath] = skn
renderer.sknToVAO[accessory.sknPath] = renderer.createTheSimsSKNModelVAO(
gl,
skn,
when (headSkin.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
)
renderer.bitmapToTexture[accessory.bmpPath] =
renderer.resourceManager.loadTexture(img).textureId
}
}
// Load all the skn and bmp files for this body model
for (bodySkin in wardrobe.body) {
for (accessory in bodySkin.accessories) {
val sknData = skinRendererResources.loadFile(accessory.sknPath)
.decodeToString()
val img = loadImage(accessory.bmpPath)
val skn = SKN.read(sknData)
renderer.sknToParsedSKN[accessory.sknPath] = skn
renderer.sknToVAO[accessory.sknPath] = renderer.createTheSimsSKNModelVAO(
gl,
skn,
when (bodySkin.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
)
renderer.bitmapToTexture[accessory.bmpPath] =
renderer.resourceManager.loadTexture(img).textureId
}
}
val programId = renderer.setupDefaultSimsShader()
println("Updating matrixes")
renderer.updateProjectionMatrix()
renderer.updateViewMatrix()
gl.enable(WebGL2RenderingContext.DEPTH_TEST)
var isClicking = false
var lastX = 0.0
canvas.addEventHandler(MouseEvent.MOUSE_DOWN) {
lastX = it.x
isClicking = true
}
canvas.addEventHandler(MouseEvent.MOUSE_UP) {
isClicking = false
}
canvas.addEventHandler(MouseEvent.MOUSE_MOVE) {
if (isClicking) {
var diff = it.x - lastX
renderer.cameraRotationY += (-diff.toFloat() * 0.04f)
lastX = it.x
}
}
var lastUpdate = 0.0
fun renderLoop(timestamp: Double) {
val delta = timestamp - lastUpdate
lastUpdate = timestamp
gl.clearColor(0f, 0f, 0f, 0f) // fully transparent
gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT + WebGL2RenderingContext.DEPTH_BUFFER_BIT)
renderer.updateViewMatrix()
val head = activeHeadSknRef.current
if (head != null) {
// println("Rendering head!")
for (accessory in head.accessories) {
val skn = renderer.sknToParsedSKN[accessory.sknPath]!!
val vao = renderer.sknToVAO[accessory.sknPath]!!
val texture = renderer.bitmapToTexture[accessory.bmpPath]!!
// println("Drawing head skn with ${skn.vertices.size} vertices and ${skn.faces.size} faces - Active wardrobe skin is $activeBodySkn")
renderer.drawTheSimsSKN(
gl,
programId,
vao,
texture,
Vector3f(0f, 8f, 0f),
skn.faces.size * 3,
when (head.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
)
}
}
val body = activeBodySknRef.current
if (body != null) {
// println("Rendering body!")
for (accessory in body.accessories) {
val skn = renderer.sknToParsedSKN[accessory.sknPath]!!
val vao = renderer.sknToVAO[accessory.sknPath]!!
val texture = renderer.bitmapToTexture[accessory.bmpPath]!!
// println("Drawing body skn with ${skn.vertices.size} vertices and ${skn.faces.size} faces - Active wardrobe skin is $activeBodySkn")
val skeletonCmx = when (body.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
renderer.drawTheSimsSKN(
gl,
programId,
vao,
texture,
Vector3f(0f, 8f, 0f),
skn.faces.size * 3,
skeletonCmx
)
val handBmpPath = accessory.handBmpPath
// Wow, this SUCKS
val handBmpPathReal = if (handBmpPath != null) {
handBmpPath
} else {
when (accessory.skinColorType) {
SkinColorType.LIGHT -> "/files/maxis/base-game/GameData/Textures/Textures.far/HUAOlgt.bmp"
SkinColorType.MEDIUM -> "/files/maxis/base-game/GameData/Textures/Textures.far/HUAOmed.bmp"
SkinColorType.DARK -> "/files/maxis/base-game/GameData/Textures/Textures.far/HUAOdrk.bmp"
}
}
val handSkinColorTexture = handsTextures[handBmpPathReal] ?: error("Missing hand texture for $handBmpPathReal!")
renderer.drawTheSimsSKN(
gl,
programId,
adultHandLeftVAO,
handSkinColorTexture,
Vector3f(0f, 8f, 0f),
handLeftSkn.faces.size * 3,
skeletonCmx
)
renderer.drawTheSimsSKN(
gl,
programId,
adultHandRightVAO,
handSkinColorTexture,
Vector3f(0f, 8f, 0f),
handRightSkn.faces.size * 3,
skeletonCmx
)
}
}
// println("Error (if available): ${gl.getError()}")
if (!isClicking)
renderer.cameraRotationY -= (0.0010f * delta.toFloat())
requestAnimationFrame { renderLoop(it) }
}
requestAnimationFrame {
lastUpdate = it
renderLoop(it)
}
}
}
canvas {
ref = canvasReference
useEffectOnce {
updateCanvas()
}
}
}
createRoot(element).render(skinRendererComponent.create())
}
}
package net.sneakysims.website.frontend.skinrenderer
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import web.window.window
/**
* A skin renderer resources cache that can be used to share resources between multiple renderer instances
*/
class SkinRendererResources {
val loadedData = mutableMapOf<String, ByteArray>()
// TODO: Remove this from here!
val http = HttpClient {}
val mutex = Mutex()
suspend fun loadFile(path: String): ByteArray {
mutex.withLock {
val cachedData = loadedData[path]
println("Attempting to load file $path... Is it cached? ${cachedData != null}")
if (cachedData != null)
return cachedData
val byteArray = http.get(window.location.origin + path)
.bodyAsBytes()
loadedData[path] = byteArray
return byteArray
}
}
}
package net.sneakysims.website.skinrenderer
import kotlinx.serialization.Serializable
@Serializable
data class SkinRendererWardrobe(
val head: List<WardrobeSkin>,
val body: List<WardrobeSkin>
) {
@Serializable
data class WardrobeSkin(
val skeletonType: SkeletonType,
val accessories: List<SkinAccessory>
)
@Serializable
data class SkinAccessory(
val skinColorType: SkinColorType,
val sknPath: String,
val bmpPath: String,
val handBmpPath: String?,
)
}
package net.sneakysims.website.contentimporter
import net.sneakysims.website.SneakySimsWebsite
import net.sneakysims.website.skinrenderer.SkeletonType
import net.sneakysims.website.skinrenderer.SkinColorType
import net.sneakysims.website.tables.CustomContentCollections
import net.sneakysims.website.tables.CustomContentTags
import net.sneakysims.website.tables.CustomContentVersions
import net.sneakysims.website.utils.CustomContentTagType
import net.sneakysims.website.utils.SlugUtils
import net.sneakysims.website.utils.TagWrapper
import net.sneakysims.website.utils.VirtualFileSystem
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.InsertStatement
import java.io.File
import java.time.OffsetDateTime
import java.time.ZoneOffset
class TheSims1ContentImporter(val m: SneakySimsWebsite) {
data class WardrobeSkin(
val skeletonType: SkeletonType,
val skinColorType: SkinColorType,
val partType: PartType,
val bodySize: BodySize,
val gender: Gender,
val sknPath: String,
val bmpPath: String
) {
enum class PartType {
HEAD,
NORMAL,
NUDE,
SWIMSUIT,
LINGERIE,
FORMAL,
WINTER,
HIGH_FASHION
}
enum class BodySize {
CHILD,
SKINNY,
FIT,
FAT
}
// Technically there is also "U" which is unissex, we need to support that later
enum class Gender {
MALE,
FEMALE
}
}
}
package net.sneakysims.website.frontend.skinrenderer
import net.perfectdreams.ddgamehard.ResourceManager
import net.perfectdreams.ddgamehard.ShaderManager
import net.perfectdreams.harmony.math.HarmonyMath
import net.perfectdreams.harmony.math.Matrix4f
import net.perfectdreams.harmony.math.Quaternionf
import net.perfectdreams.harmony.math.Vector3f
import net.sneakysims.sneakylib.cmx.CMX
import net.sneakysims.sneakylib.skn.SKN
import net.sneakysims.website.frontend.reactcomponents.toFloat32Array
import web.gl.GLenum
import web.gl.WebGL2RenderingContext
import web.gl.WebGLProgram
import web.gl.WebGLTexture
import web.gl.WebGLVertexArrayObject
class TheSims1SkinRenderer(
val width: Int,
val height: Int,
val gl: WebGL2RenderingContext
) {
val resourceManager = ResourceManager(gl)
val shaderManager = ShaderManager(gl)
val cameraPosition = Vector3f(-9f, 3f, 0f)
val cameraLookAt = Vector3f(0f, 3f, 0f)
var fov = 45.0
var cameraRotationY = HarmonyMath.toRadians(-90.0).toFloat()
lateinit var projection: Matrix4f
lateinit var view: Matrix4f
val sknToParsedSKN = mutableMapOf<String, SKN>()
val sknToVAO = mutableMapOf<String, WebGLVertexArrayObject>()
val bitmapToTexture = mutableMapOf<String, WebGLTexture>()
// VAO = Vertex Array Object
// VBO = Vertex Buffer Object
fun createTheSimsSKNModelVAO(
gl: WebGL2RenderingContext,
sknModel: SKN,
cmx: CMX
): WebGLVertexArrayObject {
val skeleton = cmx.skeletons.first()
// All values are initialized to zero
val vertices = FloatArray(sknModel.faces.size * 9)
val uvCoordinates = FloatArray(sknModel.faces.size * 3 * 2)
// Yeah, this is faces * 3
val vertexToBoneIds = FloatArray(sknModel.faces.size * 3)
println("Faces: ${sknModel.faces.size}")
println("UV Coordinates: ${sknModel.textureCoordinates.size}")
println("Vertices: ${sknModel.vertices.size}")
run {
var idx = 0
var totalUvCoordinates = 0
var uvCoordinatesIdx = 0
var boneIdx = 0
// val binding = sknModel.boneBindings.first { it.boneIndex == lArmIndex }
// println("Vert Range is $vertRange")
for (face in sknModel.faces) {
fun appendVertex(index: Int) {
val vertex = sknModel.vertices[index]
val texturesCoordinates = sknModel.textureCoordinates[index]
uvCoordinates[uvCoordinatesIdx++] = texturesCoordinates.u
uvCoordinates[uvCoordinatesIdx++] = texturesCoordinates.v
vertices[idx++] = vertex.x
vertices[idx++] = vertex.y
vertices[idx++] = vertex.z
// THIS IS CORRECT ACTUALLY
val bindings = sknModel.boneBindings.filter { index in it.firstVert until (it.firstVert + it.vertCount) }
if (bindings.size != 1)
error("Invalid Binding Size! ${bindings.size}")
val binding = bindings.first()
// We need to convert the bone index to the skeleton bone index
val boneName = sknModel.bones[binding.boneIndex]
val skeletonBoneIndex = skeleton.skeletonBones.indexOfFirst { it.boneName == boneName }
vertexToBoneIds[boneIdx++] = skeletonBoneIndex.toFloat()
}
appendVertex(face.vertex0Index)
appendVertex(face.vertex1Index)
appendVertex(face.vertex2Index)
}
println("SKN \"${sknModel.sknFileName}\" stats:")
println("Faces: ${sknModel.faces.size}")
println("Loop Index: $idx")
println("Vertices Array Size: ${vertices.size}")
println("UV Coordinates Size: ${uvCoordinates.size}")
println("Vertex To Bone IDs size: ${vertexToBoneIds.size}")
println("UV IDX: $uvCoordinatesIdx")
println("Vertices Array Size (/3): ${vertices.size / 3}")
if (idx != vertices.size)
error("Vertices array was not completely filled! Something went wrong! Index: $idx; Vertices: ${vertices.size}")
}
// Create and bind VAO
val quadVAO = gl.createVertexArray()
gl.bindVertexArray(quadVAO)
// Generate two VBOs, one for the vertices another for the textcoords
val positionBuffer = gl.createBuffer()
val textCoordsBuffer = gl.createBuffer()
val bonesBuffer = gl.createBuffer()
// Position VBO
gl.enableVertexAttribArray(0)
gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, positionBuffer)
gl.bufferData(WebGL2RenderingContext.ARRAY_BUFFER, toFloat32Array(vertices), WebGL2RenderingContext.STATIC_DRAW)
// When reading pointers like this, think like this
// The "size" is how much is the TARGET array that will be passed to the vertex shader
// The "stride" is how much data WILL BE READ
// The "pointer" is WHERE the data is in the ARRAY THAT WAS READ
gl.vertexAttribPointer(0, 3, WebGL2RenderingContext.FLOAT, false, 0, 0)
// Tex Coords VBO
gl.enableVertexAttribArray(1)
gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, textCoordsBuffer)
gl.bufferData(WebGL2RenderingContext.ARRAY_BUFFER, toFloat32Array(uvCoordinates), WebGL2RenderingContext.STATIC_DRAW)
// When reading pointers like this, think like this
// The "size" is how much is the TARGET array that will be passed to the vertex shader
// The "stride" is how much data WILL BE READ
// The "pointer" is WHERE the data is in the ARRAY THAT WAS READ
gl.vertexAttribPointer(1, 2, WebGL2RenderingContext.FLOAT, false, 0, 0)
// Bone ID VBO
gl.enableVertexAttribArray(2)
gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, bonesBuffer)
gl.bufferData(WebGL2RenderingContext.ARRAY_BUFFER, toFloat32Array(vertexToBoneIds), WebGL2RenderingContext.STATIC_DRAW)
// When reading pointers like this, think like this
// The "size" is how much is the TARGET array that will be passed to the vertex shader
// The "stride" is how much data WILL BE READ
// The "pointer" is WHERE the data is in the ARRAY THAT WAS READ
gl.vertexAttribPointer(2, 1, WebGL2RenderingContext.FLOAT, false, 0, 0)
// Unbind
gl.bindBuffer(WebGL2RenderingContext.ARRAY_BUFFER, null)
gl.bindVertexArray(null)
gl.disableVertexAttribArray(2)
gl.disableVertexAttribArray(1)
gl.disableVertexAttribArray(0)
return quadVAO
}
fun drawTheSimsSKN(gl: WebGL2RenderingContext, programId: WebGLProgram, quadVAO: WebGLVertexArrayObject, textureId: WebGLTexture, position: Vector3f, triangleCount: Int, cmx: CMX) {
// If the triangle count is bigger than what is provided in the attribs, there is a fat chance that it will be invisible
// Another thing, if ANY of the attribs (not just the vertex!) has an incorrect size, it WILL break!
gl.useProgram(programId)
// println("Error (if available): ${gl.getError()}")
gl.activeTexture(WebGL2RenderingContext.TEXTURE0)
// println("Error (if available): ${gl.getError()}")
gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, textureId)
// println("Error (if available): ${gl.getError()}")
// val location = glGetUniformLocation(programId, "MVP")
val modelLocation = gl.getUniformLocation(programId, "model")
// println("Error (if available): ${gl.getError()}")
val viewLocation = gl.getUniformLocation(programId, "view")
// println("Error (if available): ${gl.getError()}")
val projectionLocation = gl.getUniformLocation(programId, "projection")
// println("Error (if available): ${gl.getError()}")
val boneMatricesLocation = gl.getUniformLocation(programId, "boneMatrices")
// println("Error (if available): ${gl.getError()}")
// The bones should ALWAYS be filled with the identity matrix if they aren't posed
val stuff = FloatArray(16 * cmx.skeletons.first().skeletonBones.size)
val bones = mutableMapOf<String, Matrix4f>()
bones["NULL"] = Matrix4f()
.scale(1f, 1f, -1f)
.translate(0f, -8f, 0f)
// .rotateZ(Math.toRadians(-180.0).toFloat())
repeat(cmx.skeletons.first().skeletonBones.size) {
val suit = cmx.skeletons.first().skeletonBones[it]
// println("Processing suit ${suit.boneName} ($it)")
val parentBone = bones[suit.parentBone] ?: error("Missing parent bone for ${suit.boneName}! Parent Bone is ${suit.parentBone}")
// println("Suit ${suit.boneName} attaches to parent ${suit.parentBone}")
// intentionally inverted
val thisBone = Matrix4f(parentBone)
thisBone
.apply {
translate(suit.position)
// I'm not sure why do we need to invert
// Milkshape 3D's skeleton shows up "correctly", but inverting also looks like a correct skeleton?!
// I think that Milkshape 3D is actually WRONG because this skeleton actually looks more correct than the one by MS3D
// After all, why would the toe bones be, by default, BELOW the feet?
rotate(Quaternionf(suit.rotation).invert(), this)
}
.get(stuff, 16 * it)
bones[suit.boneName] = thisBone
}
// Model matrix: where the mesh is in the world
val model = Matrix4f()
.translate(position)
// Our ModelViewProjection: multiplication of our 3 matrices
// val mvp = projection.mul(view, Matrix4f()).mul(model, Matrix4f()) // Remember, matrix multiplication is the other way around
gl.uniformMatrix4fv(modelLocation, false, toFloat32Array(model.getAsFloatArray()), null, null)
// println("Error (if available / uniformMatrix4fv model): ${gl.getError()}")
gl.uniformMatrix4fv(viewLocation, false, toFloat32Array(view.getAsFloatArray()), null, null)
// println("Error (if available / uniformMatrix4fv view): ${gl.getError()}")
gl.uniformMatrix4fv(projectionLocation, false, toFloat32Array(projection.getAsFloatArray()), null, null)
// println("Error (if available / uniformMatrix4fv projection): ${gl.getError()}")
gl.uniformMatrix4fv(boneMatricesLocation, false, toFloat32Array(stuff), null, null)
// println("Error (if available / uniformMatrix4fv stuff): ${gl.getError()}")
// glUniform1i(isActiveLocation, if (isActive) 1 else 0)
// glUniform1f(timeLocation, GLFW.glfwGetTime().toFloat())
gl.bindVertexArray(quadVAO)
// println("Error (if available / bind vertex): ${gl.getError()}")
gl.drawArrays(WebGL2RenderingContext.TRIANGLES, 0, triangleCount)
// println("Error (if available / drawArrays): ${gl.getError()}")
gl.bindVertexArray(null)
// println("Error (if available / null bind): ${gl.getError()}")
}
/**
* Setups the default The Sims-like shader used by the renderer
*/
fun setupDefaultSimsShader(): WebGLProgram {
println("Compiling shader")
val programId = shaderManager.loadShader(
"""
#version 300 es
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 TexCoords;
layout (location = 2) in float BoneId; // Which bone are we affected by, currently we will only support ONE SINGULAR BONE
// Values that stay constant for the whole mesh.
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
out vec2 FragTexCoords;
out float FragBoneId;
uniform mat4 boneMatrices[29];
void main() {
FragTexCoords = TexCoords;
FragBoneId = BoneId;
// Initialize transformed position
vec4 transformedPos = vec4(0.0);
// Apply bone transformations
mat4 boneTransform = boneMatrices[int(BoneId)]; // Convert float to int
transformedPos += (boneTransform * vec4(aPos, 1.0));
mat4 mvp = projection * view * model;
gl_Position = mvp * vec4(transformedPos.x, transformedPos.y, transformedPos.z, 1.0);
}
""".trimIndent(),
"""
#version 300 es
precision highp float;
in vec2 FragTexCoords;
in float FragBoneId;
out vec4 FragColor;
uniform sampler2D image;
void main() {
// if (FragBoneId == 20.0f) {
// FragColor = vec4(0.0f, 1.0f, 1.0f, 1.0f);
// return;
// }
FragColor = texture(image, FragTexCoords);
// FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
""".trimIndent()
)
return programId
}
/**
* Updates the current projection matrix
*/
fun updateProjectionMatrix() {
// We have a function to update the view matrix because we can switch the projection during runtime
val projection = Matrix4f()
// Projection matrix: 45° Field of View, 4:3 ratio, display range: 0.1 unit <-> 100 units
projection.perspective(HarmonyMath.toRadians(fov).toFloat(), width.toFloat() / height.toFloat(), 0.1f, 100.0f, false)
this.projection = projection
}
/**
* Updates the current view matrix
*/
fun updateViewMatrix() {
// We have a function to update the view matrix due to the camera rotation
val cameraPosition = Vector3f(this.cameraPosition)
.rotateY(cameraRotationY)
// println("Camera Position: ${this.cameraPosition.x}, ${this.cameraPosition.y}, ${this.cameraPosition.z}")
this.view = Matrix4f().lookAt(
cameraPosition,
this.cameraLookAt,
Vector3f(0f, 1f, 0f) // Head is up (set to 0,-1,0 to look upside-down)
)
}
fun logIfGLError() {
val error = gl.getError()
if (error != WebGL2RenderingContext.NO_ERROR) {
error("GL Error! $error")
}
}
}
package net.sneakysims.website.frontend.reactcomponents
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.perfectdreams.harmony.math.Vector3f
import net.sneakysims.sneakylib.cmx.CMX
import net.sneakysims.sneakylib.skn.SKN
import net.sneakysims.website.frontend.loadImage
import net.sneakysims.website.frontend.plainStyle
import net.sneakysims.website.frontend.skinrenderer.SkinRendererResources
import net.sneakysims.website.frontend.skinrenderer.TheSims1SkinRenderer
import net.sneakysims.website.skinrenderer.SkeletonType
import net.sneakysims.website.skinrenderer.SkinRendererWardrobe
import react.FC
import react.Props
import react.dom.html.ReactHTML.canvas
import react.dom.html.ReactHTML.div
import react.useEffectOnce
import react.useRef
import web.cssom.ClassName
import web.gl.WebGL2RenderingContext
import web.html.HTMLCanvasElement
// A The Sims Online UI-like body skin button
external interface TSOBodySkinSelectorProps : Props {
var onClick: () -> (Unit)
var selected: Boolean
var wardrobeSkin: SkinRendererWardrobe.WardrobeSkin
var skinRendererResources: SkinRendererResources
}
val TSOBodySkinSelector = FC<TSOBodySkinSelectorProps>("TSOBodySkinSelectorProps") { props ->
div {
val canvasReference = useRef<HTMLCanvasElement>(null)
className = ClassName("tso-skin-selector-button")
if (props.selected)
className = ClassName("tso-skin-selector-button active-skin")
plainStyle = "width: 48px;height: 110px;"
onClick = { props.onClick() }
fun renderSkin() {
val canvas = canvasReference.current!!
// TODO: Render skin somehow?
canvas.style.width = "100%"
canvas.style.height = "100%"
canvas.width = 44 * 2
canvas.height = 106 * 2
val gl = canvas.getContext(WebGL2RenderingContext.ID)!!
gl.enable(WebGL2RenderingContext.DEPTH_TEST)
gl.clearColor(0f, 0f, 0f, 0f)
gl.clear(WebGL2RenderingContext.DEPTH_BUFFER_BIT + WebGL2RenderingContext.COLOR_BUFFER_BIT)
val renderer = TheSims1SkinRenderer(44, 106, gl)
GlobalScope.launch {
val adultCmxData = props.skinRendererResources.loadFile("/assets/mikutest/adult-skeleton.cmx")
.decodeToString()
val childCmxData = props.skinRendererResources.loadFile("/assets/mikutest/child-skeleton.cmx")
.decodeToString()
val adultCmx = CMX.read(adultCmxData)
val childCmx = CMX.read(childCmxData)
val skeletonCmx = when (props.wardrobeSkin.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
for (accessory in props.wardrobeSkin.accessories) {
val sknData = props.skinRendererResources.loadFile(accessory.sknPath).decodeToString()
val img = loadImage(accessory.bmpPath)
val skn = SKN.read(sknData)
renderer.sknToParsedSKN[accessory.sknPath] = skn
renderer.sknToVAO[accessory.sknPath] = renderer.createTheSimsSKNModelVAO(
gl,
skn,
skeletonCmx
)
renderer.bitmapToTexture[accessory.bmpPath] = renderer.resourceManager.loadTexture(img).textureId
}
val programId = renderer.setupDefaultSimsShader()
println("Updating matrixes")
renderer.updateProjectionMatrix()
renderer.updateViewMatrix()
for (accessory in props.wardrobeSkin.accessories) {
val skn = renderer.sknToParsedSKN[accessory.sknPath]!!
val vao = renderer.sknToVAO[accessory.sknPath]!!
val texture = renderer.bitmapToTexture[accessory.bmpPath]!!
renderer.drawTheSimsSKN(
gl,
programId,
vao,
texture,
Vector3f(0f, 8f, 0f),
skn.faces.size * 3,
skeletonCmx
)
}
}
}
canvas {
ref = canvasReference
useEffectOnce {
renderSkin()
}
}
}
}
package net.sneakysims.website.frontend.reactcomponents
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import net.perfectdreams.harmony.math.Vector3f
import net.sneakysims.sneakylib.cmx.CMX
import net.sneakysims.sneakylib.skn.SKN
import net.sneakysims.website.frontend.loadImage
import net.sneakysims.website.frontend.plainStyle
import net.sneakysims.website.frontend.skinrenderer.SkinRendererResources
import net.sneakysims.website.frontend.skinrenderer.TheSims1SkinRenderer
import net.sneakysims.website.skinrenderer.SkeletonType
import net.sneakysims.website.skinrenderer.SkinRendererWardrobe
import react.FC
import react.Props
import react.dom.html.ReactHTML.canvas
import react.dom.html.ReactHTML.div
import react.useEffectOnce
import react.useRef
import web.cssom.ClassName
import web.gl.WebGL2RenderingContext
import web.html.HTMLCanvasElement
// A The Sims Online UI-like head skin button
external interface TSOHeadSkinSelectorProps : Props {
var onClick: () -> (Unit)
var selected: Boolean
var wardrobeSkin: SkinRendererWardrobe.WardrobeSkin
var skinRendererResources: SkinRendererResources
}
val TSOHeadSkinSelector = FC<TSOHeadSkinSelectorProps>("TSOHeadSkinSelectorProps") { props ->
div {
val canvasReference = useRef<HTMLCanvasElement>(null)
className = ClassName("tso-skin-selector-button")
if (props.selected)
className = ClassName("tso-skin-selector-button active-skin")
plainStyle = "width: 48px;height: 48px;"
onClick = { props.onClick() }
fun renderSkin() {
val canvas = canvasReference.current!!
// TODO: Render skin somehow?
canvas.style.width = "100%"
canvas.style.height = "100%"
canvas.width = 44 * 2
canvas.height = 44 * 2
val gl = canvas.getContext(WebGL2RenderingContext.ID)!!
gl.enable(WebGL2RenderingContext.DEPTH_TEST)
gl.clearColor(0f, 0f, 0f, 0f)
gl.clear(WebGL2RenderingContext.DEPTH_BUFFER_BIT + WebGL2RenderingContext.COLOR_BUFFER_BIT)
val renderer = TheSims1SkinRenderer(44, 44, gl)
GlobalScope.launch {
val adultCmxData = props.skinRendererResources.loadFile("/assets/mikutest/adult-skeleton.cmx")
.decodeToString()
val childCmxData = props.skinRendererResources.loadFile("/assets/mikutest/child-skeleton.cmx")
.decodeToString()
val adultCmx = CMX.read(adultCmxData)
val childCmx = CMX.read(childCmxData)
val skeletonCmx = when (props.wardrobeSkin.skeletonType) {
SkeletonType.ADULT -> adultCmx
SkeletonType.CHILD -> childCmx
}
when (props.wardrobeSkin.skeletonType) {
SkeletonType.ADULT -> {
renderer.cameraPosition.x = -2.5f
renderer.cameraPosition.y = 5.2f
renderer.cameraPosition.z = 0f
renderer.cameraLookAt.x = 0f
renderer.cameraLookAt.y = 5.2f
renderer.cameraLookAt.z = 0f
}
SkeletonType.CHILD -> {
renderer.cameraPosition.x = -2.5f
renderer.cameraPosition.y = 4.05f
renderer.cameraPosition.z = 0f
renderer.cameraLookAt.x = 0f
renderer.cameraLookAt.y = 4.05f
renderer.cameraLookAt.z = 0f
}
}
renderer.fov = 22.0
for (accessory in props.wardrobeSkin.accessories) {
val sknData = props.skinRendererResources.loadFile(accessory.sknPath).decodeToString()
val img = loadImage(accessory.bmpPath)
val skn = SKN.read(sknData)
renderer.sknToParsedSKN[accessory.sknPath] = skn
renderer.sknToVAO[accessory.sknPath] = renderer.createTheSimsSKNModelVAO(
gl,
skn,
skeletonCmx
)
renderer.bitmapToTexture[accessory.bmpPath] = renderer.resourceManager.loadTexture(img).textureId
}
val programId = renderer.setupDefaultSimsShader()
println("Updating matrixes")
renderer.updateProjectionMatrix()
renderer.updateViewMatrix()
for (accessory in props.wardrobeSkin.accessories) {
val skn = renderer.sknToParsedSKN[accessory.sknPath]!!
val vao = renderer.sknToVAO[accessory.sknPath]!!
val texture = renderer.bitmapToTexture[accessory.bmpPath]!!
renderer.drawTheSimsSKN(
gl,
programId,
vao,
texture,
Vector3f(0f, 8f, 0f),
skn.faces.size * 3,
skeletonCmx
)
}
}
}
canvas {
ref = canvasReference
useEffectOnce {
renderSkin()
}
}
}
}
package net.sneakysims.website.utils
import net.sneakysims.website.utils.VirtualFileSystem.VirtualFile.VirtualByteArrayFile
import net.sneakysims.website.utils.VirtualFileSystem.VirtualFile.VirtualRealFile
import net.sneakysims.website.utils.VirtualFileSystem.VirtualFile.VirtualZIPFile
import java.io.File
import java.nio.charset.Charset
import java.nio.charset.MalformedInputException
import java.time.Instant
import java.util.zip.ZipInputStream
/**
* A virtual file system that can be used to map files inside ZIP files and inside real folders, similar to how Minecraft's assets are loaded
*
* This is also similar to how The Sims 1's assets are loaded too
*
* @param ignoreCase if true, all file names will be case-insensitive
*/
// TODO: Maybe implement a file "link"? it would work like a rename but the original path is saved
class VirtualFileSystem(val ignoreCase: Boolean) {
private val files = mutableMapOf<String, VirtualFile>()
fun addVirtualFileSystem(virtualFileSystem: VirtualFileSystem) {
for (file in virtualFileSystem.getAllFiles()) {
addVirtualFile(file.name, file)
}
}
fun addFolder(folder: File) {
for (file in folder.listFiles()) {
addVirtualFile(file.name, VirtualRealFile(file.name, file))
}
}
fun addZIP(zipFile: File) {
// This is a hack to figure out if someone is using japanese characters in their ZIP
try {
ZipInputStream(zipFile.inputStream()).use { zipInputStream ->
while (true) {
val entry = zipInputStream.nextEntry ?: break
if (!entry.isDirectory)
addVirtualFile(
entry.name,
VirtualZIPFile(entry.name, entry.name, zipFile, entry.lastModifiedTime.toInstant())
)
}
}
} catch (e: IllegalArgumentException) {
ZipInputStream(zipFile.inputStream(), Charset.forName("Shift-JIS")).use { zipInputStream ->
while (true) {
val entry = zipInputStream.nextEntry ?: break
if (!entry.isDirectory)
addVirtualFile(
entry.name,
VirtualZIPFile(entry.name, entry.name, zipFile, entry.lastModifiedTime.toInstant())
)
}
}
}
}
fun addZIP(zipFile: File, charset: Charset) {
ZipInputStream(zipFile.inputStream()).use { zipInputStream ->
while (true) {
val entry = zipInputStream.nextEntry ?: break
if (!entry.isDirectory)
addVirtualFile(entry.name, VirtualZIPFile(entry.name, entry.name, zipFile, entry.lastModifiedTime.toInstant()))
}
}
}
fun addZIP(zipFileData: ByteArray) {
ZipInputStream(zipFileData.inputStream()).use { zipInputStream ->
while (true) {
val entry = zipInputStream.nextEntry ?: break
// Because the ZIP file is in memory we need to also keep the files in memory too
// This is very memory intensive, so maybe we need to refactor this later
if (!entry.isDirectory)
addVirtualFile(entry.name, VirtualByteArrayFile(entry.name, zipInputStream.readAllBytes(), entry.lastModifiedTime.toInstant()))
}
}
}
fun addVirtualFile(refName: String, virtualFile: VirtualFile) {
files[transformFileName(refName)] = virtualFile
}
fun file(file: String): VirtualFile = fileOrNull(file) ?: error("File \"$file\" does not exist inside the Virtual File System!")
fun fileOrNull(file: String): VirtualFile? {
return this.files[transformFileName(file)]
}
fun hasFile(file: String) = fileOrNull(file) != null
fun getAllFiles() = files.values.toList()
fun renameFile(file: VirtualFile, newName: String) {
val wasRemoved = this.files.remove(transformFileName(file.name), file)
if (!wasRemoved)
error("Attempted to rename file $file, however the file is not present in the virtual file system! Bug?")
this.files[transformFileName(newName)] = file.createNewWithName(newName)
}
private fun transformFileName(name: String): String {
return if (ignoreCase)
name.lowercase()
else
name
}
sealed class VirtualFile(val name: String) {
val extension
get() = name.substringAfterLast(".")
abstract val lastModifiedTime: Instant
abstract fun readBytes(): ByteArray
fun readText(): String {
return readBytes().toString(Charsets.UTF_8)
}
abstract fun createNewWithName(newName: String): VirtualFile
class VirtualByteArrayFile(
name: String,
val data: ByteArray,
override val lastModifiedTime: Instant
) : VirtualFile(name) {
override fun readBytes(): ByteArray {
return this.data
}
override fun createNewWithName(newName: String) = VirtualByteArrayFile(newName, data, lastModifiedTime)
}
class VirtualRealFile(
name: String,
val file: File
) : VirtualFile(name) {
override val lastModifiedTime
get() = Instant.ofEpochMilli(file.lastModified())
override fun readBytes(): ByteArray {
return this.file.readBytes()
}
override fun createNewWithName(newName: String) = VirtualRealFile(newName, file)
}
class VirtualZIPFile(
name: String,
val referenceName: String,
val sourceZIP: File,
override val lastModifiedTime: Instant
) : VirtualFile(name) {
override fun readBytes(): ByteArray {
ZipInputStream(sourceZIP.inputStream()).use { zipInputStream ->
while (true) {
// TODO: Seek support?
val entry = zipInputStream.nextEntry ?: break
entry.lastModifiedTime
if (entry.name == this.referenceName)
return zipInputStream.readAllBytes()
}
}
error("File $referenceName (VFS name: $name) does not exist inside ZIP file! Bug?")
}
override fun createNewWithName(newName: String) = VirtualZIPFile(newName, referenceName, sourceZIP, lastModifiedTime)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment