Last active
March 13, 2025 04:25
-
-
Save MrPowerGamerBR/a343d15765949416c1fcef94cec43696 to your computer and use it in GitHub Desktop.
SneakySims' The Sims 1 Skin Renderer (frontend & backend)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package net.sneakysims.website.skinrenderer | |
enum class SkeletonType { | |
ADULT, | |
CHILD | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package net.sneakysims.website.skinrenderer | |
enum class SkinColorType(val code: String) { | |
LIGHT("lgt"), | |
MEDIUM("med"), | |
DARK("drk") | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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?, | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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