diff --git a/README.md b/README.md index f5539a0..f35a4f4 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ [![Build](https://github.com/way-zer/MindustryContents/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/way-zer/MindustryContents/actions/workflows/build.yml) [![](https://jitpack.io/v/way-zer/MindustryContents.svg)](https://jitpack.io/#way-zer/MindustryContents) + # MindustryContentsLoader + 一个`内容包`加载器的像素工厂MOD -A Mindustry MOD to dynamicly load `Contents Pack` +A Mindustry MOD to dynamicly load `Contents Pack` and `Contents Patch` ## 功能 Features -* 接受服务器指令,为加载下张地图时,更换指定的`内容包` -* Receive Info from Server, load special `Contents Pack` when join server or change map. -* 为其他MOD提供接口,提供动态加载`内容包`的能力 -* Provide API for other mods, provide feature to dynamicly load `Contents Pack` + +* 接受服务器指令,为加载下张地图时,更换指定的`内容包`和`内容补丁` +* Receive Info from Server, load special `Contents Pack` or `Contents Patch` when join server or change map. +* 为其他MOD提供接口,提供动态加载`内容包`和`内容补丁`的能力 +* Provide API for other mods, provide feature to dynamicly load `Contents Pack` or `Contents Patch` ## 内容包定义 Defination for `Contents Pack` + 一组ContentList代码,没有属性,仅包含load函数,为原版Contents赋值 A group of ContentList code, NO member, ONLY functionn `load` to assign new instances to original contents. @@ -18,19 +22,91 @@ A group of ContentList code, NO member, ONLY functionn `load` to assign new inst For example, see [flood ContentPack](./contents/flood) ### maven引用 import from maven + 供插件或其他项目引用 For plugin or other projects to import. + ```groovy -repositories { - maven { url 'https://jitpack.io' } +repositories{ + maven{ url 'https://jitpack.io' } } -dependencies { +dependencies{ implementation 'cf.wayzer.MindustryContents:CONTENT_TYPE:VERSION' } ``` + * VERSION: 编译版本号 build version * CONTENT_TYPE: 内容包类型(如flood) name of `Contents Pack`(ex `flood`) +## 内容补丁 Defination for `Contents Patch` + +一个(h)json文件,可以修改游戏内所有物品的属性 +A (h)json file. According to it modify all contents property. + +客户端将会自动加载`config/contents-patch/default.(h)json`文件(如果存在), +并且根据地图信息或服务器指令,加载补丁(如果不存在,会自动从服务器下载) +Client will auto load patch in `config/contents-patch/default.(h)json` (if exists) +And will load patch according to map info or server command.(May auto download patch for server) + +### 与内容包对比 vs `Contents Pack` + +内容补丁为新功能,相比有诸多优势。支持服务器发送,支持离线游玩,抗sync,支持游戏中加载(无需重新加载地图)。 +以及更好的Mod兼容性,使用Patch甚至可以修改其他Mod的属性。 +`Contents Patch` is a newer feature, many advantages: Support offline, Anti `sync`, Load when play(no need to reload world). +And better mod compatibility, you can even patch other mod content. + +局限性:不能修改物品的类型; 不能使用java代码 +Limit: Can't change content type; Can't use java code. + +### 示例 Exmaple + +```json5 +{ + //ContentType + block: { + //Content name + "copper-wall-large": { + //Property to modify + //Value is format of origin json + "health": 1200 + }, + "phase-wall": { + "chanceDeflect": 0, + "absorbLasers": true + }, + "force-projector": { + "shieldHealth": 2500 + }, + "lancer": { + "shootType.damage": 30, + "requirements": [ + "copper/10", + "lead/100" + ] + }, + }, +} +``` + +### 网络协议 Protocal + +* map tag: `ContentsPatch` + 地图所需patch列表 List of patch names + 例(For example): `flood;patchA;xxxx` +* S->C ContentsLoader|loadPatch + 命令客户端加载一个补丁(传递参数: 仅name) + command client to load a patch (param only name) +* C-> ContentsLoader|requestPatch + 客户端找不到时,向服务器请求patch(传递参数: 仅name) + send when client not found this patch locally (param only name) +* S->C ContentsLoader|newPatch + 命令客户端加载一个新补丁,通常作为`requestPatch`的回复(传递参数: name & content) + command client to load a patch, normally as respond of `requestPatch` (params: name & content) + +通常来说,补丁名应该为其内容的hash,方便客户端进行缓存. +Normally patch's name should be a hash of content, which can be cached currently. + ## 安装 Setup + 安装Release中的MOD即可(多人游戏兼容) Install mod in Release(multiplayer compatible) diff --git a/core/src/cf/wayzer/ContentsLoader.kt b/core/src/cf/wayzer/ContentsLoader.kt index 4e25483..4315769 100644 --- a/core/src/cf/wayzer/ContentsLoader.kt +++ b/core/src/cf/wayzer/ContentsLoader.kt @@ -133,7 +133,7 @@ object ContentsLoader : ContentLoader() { "origin" to { contents.forEach { it.content = it.default } } ) val toLoadPacks = mutableListOf() - var lastLoadedPacks = listOf() + var lastLoadedPacks = listOf("origin") private set //platform impl diff --git a/core/src/cf/wayzer/ContentsPatcher.kt b/core/src/cf/wayzer/ContentsPatcher.kt new file mode 100644 index 0000000..e861702 --- /dev/null +++ b/core/src/cf/wayzer/ContentsPatcher.kt @@ -0,0 +1,119 @@ +package cf.wayzer + +import arc.util.Log +import arc.util.Strings +import arc.util.serialization.Json +import arc.util.serialization.JsonValue +import arc.util.serialization.JsonWriter +import cf.wayzer.util.reflectDelegate +import mindustry.Vars +import mindustry.content.Bullets +import mindustry.ctype.Content +import mindustry.ctype.ContentType +import mindustry.entities.bullet.BulletType +import mindustry.io.JsonIO +import mindustry.mod.ContentParser +import mindustry.mod.Mods +import mindustry.type.Item +import mindustry.world.consumers.* +import java.lang.reflect.Field + +object ContentsPatcher { + private val Mods.parser: ContentParser by reflectDelegate() + private val ContentParser.parser: Json by reflectDelegate() + private val json by lazy { Vars.mods.parser.parser } + private val bulletMap by lazy { + Bullets::class.java.fields + .filter { it.type == BulletType::class.java } + .associate { it.name to it.get(null) as BulletType } + } + + + private fun findContent(type: ContentType, name: String): Content { + return when (type) { + ContentType.bullet -> bulletMap[Strings.kebabToCamel(name)] + else -> Vars.content.getByName(type, Strings.camelToKebab(name)) + } ?: error("Not found $type : $name") + } + + @Suppress("UNCHECKED_CAST") + fun readType(cls: Class, jsonValue: JsonValue): T { + return when (cls) { + Consumers::class.java -> Consumers().apply { + jsonValue.forEach { child -> + when (child.name) { + "item" -> item(findContent(ContentType.item, child.asString()) as Item) + "items" -> add(readType(ConsumeItems::class.java, child)) + "liquid" -> add(readType(ConsumeLiquid::class.java, child)) + "coolant" -> add(readType(ConsumeCoolant::class.java, child)) + "power" -> { + if (child.isNumber) power(child.asFloat()) + else add(readType(ConsumePower::class.java, child)) + } + "powerBuffered" -> powerBuffered(child.asFloat()) + else -> error("Unknown consumption type: ${child.name}") + } + } + init() + } as T + else -> json.readValue(cls, jsonValue) + } + } + + private val bakField = mutableMapOf Unit>() + private fun handleContent(type: String, value: JsonValue) { + val content: Any = findContent(ContentType.valueOf(type), value.name) + value.forEach { prop -> + val id = "$type.${value.name}.${prop.name}" + try { + val (obj, field) = resolveObj(content, prop.name) + val bakV = field.get(obj) + bakField.putIfAbsent(id) { field.set(obj, bakV) } + field.set(obj, readType(field.type, prop)) + Log.info("Load Content $id = ${prop.toJson(JsonWriter.OutputType.javascript)}") + } catch (e: Throwable) { + Log.err("Fail to handle Content \"$id\"", e) + } + } + } + + private val fieldCache = mutableMapOf, String>, Field>() + private fun getField(obj: Any, name: String): Field { + var cls = obj.javaClass + if (cls.isAnonymousClass) + cls = cls.superclass + return fieldCache.getOrPut(cls to name) { + cls.getField(name).apply { + if (name == "consumes") + isAccessible = true + } + } + } + + private fun resolveObj(obj: Any, key: String): Pair { + var o = obj + var field: Field? = null + for (it in key.split(".")) { + if (field != null) o = field.get(o) + field = getField(o, it) + } + return o to field!! + } + + object Api { + const val tagName = "ContentsPatch" + fun load(text: String) { + val json = JsonIO.read(null, text) + json.forEach { type -> + type.forEach { content -> + handleContent(type.name, content) + } + } + } + + fun reset() { + bakField.values.forEach { it.invoke() } + bakField.clear() + } + } +} \ No newline at end of file diff --git a/core/src/cf/wayzer/util/reflect.kt b/core/src/cf/wayzer/util/reflect.kt new file mode 100644 index 0000000..44b0da5 --- /dev/null +++ b/core/src/cf/wayzer/util/reflect.kt @@ -0,0 +1,19 @@ +package cf.wayzer.util + +import java.lang.reflect.Field +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +class ReflectDelegate( + private val field: Field, private val cls: Class +) : ReadWriteProperty { + override fun getValue(thisRef: T?, property: KProperty<*>): R = cls.cast(field.get(thisRef)) + override fun setValue(thisRef: T?, property: KProperty<*>, value: R) = field.set(thisRef, value) +} + +inline fun reflectDelegate() = PropertyDelegateProvider> { _, property -> + val field = T::class.java.getDeclaredField(property.name) + field.isAccessible = true + ReflectDelegate(field, R::class.java) +} \ No newline at end of file diff --git a/loaderMod/src/main/kotlin/cf/wayzer/contentsMod/Main.kt b/loaderMod/src/main/kotlin/cf/wayzer/contentsMod/Main.kt index 409d72d..faab1da 100644 --- a/loaderMod/src/main/kotlin/cf/wayzer/contentsMod/Main.kt +++ b/loaderMod/src/main/kotlin/cf/wayzer/contentsMod/Main.kt @@ -1,13 +1,17 @@ package cf.wayzer.contentsMod import Contents +import arc.Events import arc.func.Cons import arc.func.Prov import arc.struct.ObjectMap import arc.struct.Seq import arc.util.Log import cf.wayzer.ContentsLoader +import cf.wayzer.ContentsPatcher import mindustry.Vars +import mindustry.game.EventType.PlayEvent +import mindustry.game.EventType.ResetEvent import mindustry.gen.Call import mindustry.gen.ClientPacketReliableCallPacket import mindustry.mod.Mod @@ -55,6 +59,7 @@ class Main : Mod() { Vars.netClient.addPacketHandler("ContentsLoader|load", ContentsLoader.Api.toLoadPacks::add) Log.infoTag("ContentsLoader", "Finish Load Mod") + registerContentsParser() } fun beforeWorldLoad() { @@ -62,6 +67,33 @@ class Main : Mod() { val notFound = mutableListOf() ContentsLoader.Api.loadContent(notFound) Call.serverPacketReliable("ContentsLoader|load", "LOADED: ${ContentsLoader.Api.lastLoadedPacks}") - Call.serverPacketReliable("ContentsLoader|load", "NOTFOUND: $notFound") + if (notFound.isNotEmpty()) + Call.serverPacketReliable("ContentsLoader|load", "NOTFOUND: $notFound") + } + + val patchCache = mutableMapOf() + fun registerContentsParser() { + Events.on(ResetEvent::class.java) { ContentsPatcher.Api.reset() } + fun loadPatch(name: String) { + if (name !in patchCache) { + val localFile = Vars.dataDirectory.child("contents-patch").run { + child("$name.hjson").takeIf { it.exists() } + ?: child("$name.json").takeIf { it.exists() } + } ?: return Call.serverPacketReliable("ContentsLoader|requestPatch", name) + patchCache[name] = localFile.readString() + } + ContentsPatcher.Api.load(patchCache[name]!!) + } + Events.on(PlayEvent::class.java) { + loadPatch("default") + val list = Vars.state.rules.tags.get(ContentsPatcher.Api.tagName) ?: return@on + list.split(";").forEach { loadPatch(it) } + } + Vars.netClient.addPacketHandler("ContentsLoader|loadPatch", ::loadPatch) + Vars.netClient.addPacketHandler("ContentsLoader|newPatch") { + val (name, content) = it.split('\n', limit = 2) + patchCache[name] = content + ContentsPatcher.Api.load(content) + } } } \ No newline at end of file