From fe8fa28e5649152c7b2f1f3cbcc5d830ff32f360 Mon Sep 17 00:00:00 2001 From: Linnea Gräf Date: Mon, 29 Jan 2024 11:44:46 +0100 Subject: Add remaining tutorials from my old PR --- .github/workflows/deploy.yml | 2 + .gitignore | 2 +- docs/commands.md | 137 +++++++++++++++++ docs/events.md | 111 ++++++++++++++ docs/img/background-screen.png | Bin 0 -> 19792 bytes docs/img/background.png | Bin 0 -> 467 bytes docs/img/color-selector-screen.png | Bin 0 -> 18548 bytes docs/img/text-alignment.png | Bin 0 -> 8135 bytes docs/img/uvs.jpg | Bin 0 -> 15366 bytes docs/index.md | 4 +- docs/mixins/accessors.md | 45 ++++++ docs/mixins/adding-fields.md | 50 +++++++ docs/mixins/advanced-injects.md | 80 ++++++++++ docs/mixins/index.md | 79 ++++++++++ docs/mixins/simple-injects.md | 133 +++++++++++++++++ docs/screens.md | 298 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 58 +++++++- requirements.txt | 3 +- serve.sh | 12 ++ 19 files changed, 1009 insertions(+), 5 deletions(-) create mode 100644 docs/commands.md create mode 100644 docs/events.md create mode 100644 docs/img/background-screen.png create mode 100644 docs/img/background.png create mode 100644 docs/img/color-selector-screen.png create mode 100644 docs/img/text-alignment.png create mode 100644 docs/img/uvs.jpg create mode 100644 docs/mixins/accessors.md create mode 100644 docs/mixins/adding-fields.md create mode 100644 docs/mixins/advanced-injects.md create mode 100644 docs/mixins/index.md create mode 100644 docs/mixins/simple-injects.md create mode 100644 docs/screens.md create mode 100755 serve.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4499b6c..f097d39 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - uses: actions/setup-python@v4 with: python-version: 3.x diff --git a/.gitignore b/.gitignore index 1adc467..9edfe63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ site/ - +venv/ diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..4cd38d1 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,137 @@ +# Creating your first command + +This tutorial focuses on client commands, meaning they will get run on the client. If you want to develop a server command there are more considerations to be done (like permissions, and synchronizing server state to the client). + +## Basic command class + +First, let's create a new class for our command. We will call it `CrashCommand` because it will crash your game. Of course, your command can do whatever you want. We need to make sure our command `:::java extends CommandBase`. + +```java +public class CrashCommand extends CommandBase { + + @Override + public String getCommandName() { + return "crashme"; + } + + @Override + public String getCommandUsage(ICommandSender sender) { + return ""; + } + + @Override + public void processCommand(ICommandSender sender, String[] args) throws CommandException { + throw new RuntimeException("Not yet implemented!"); + } + + @Override + public boolean canCommandSenderUseCommand(ICommandSender sender) { + return true; + } + +} +``` + +I already implemented three methods into the command. + +!!! warning + When writing a client command you will need to override `canCommandSenderUseCommand`. By default this method does not generate, but without it you will get a `You do not have permission to use this command` error (since you by default do not have any permissions on a server). Just always return `:::java true`, since the command is client side only anyway. + + +First the method `getCommandName`, which returns the name of your command. The name is what you use in chat to run the command. The command name should be just numbers and letters (and maybe dashes, if you want). In chat you will need to run `/crashme` to run this command (the `/` gets added to the name automatically). + +The second method is `getCommandUsage`. You need to override it because every commands needs to have a usage, but for most SkyBlock mods that usage doesn't matter — it only gets displayed in the `/help` menu, and Hypixel does not show client commands in it's help menu. + +## Running your command + +The `processCommand` method is run when your command is executed: + + +```java +@Override +public void processCommand(ICommandSender sender, String[] args) throws CommandException { + LogManager.getLogger("CrashCommand").info("Intentionally crashing the Game!"); + FMLCommonHandler.instance().exitJava(1, false); +} +``` + +!!! info + When using a Logger, make sure to use the `LogManager` from `org.apache.logging.log4j.LogManager`. Using the other log managers won't work. + +!!! info + If you want to close the game, you need to use `:::java FMLCommonHandler.instance().exitJava(exitCode, false)` instead of `:::java System.exit()`. Forge disables the normal `:::java System.exit()` calls. + +But, this way of crashing the game might be a bit too easy to accidentally run. So let's add a confirmation system. When your `processCommand` is called, you are given two arguments: the `sender` is always the current player (since this is a client command), and the `args` array gives you all the arguments you are being called with. If a player runs the command `/crashme foo bar`, args will be `:::java new String[] {"foo", "bar"}`. + +```java +@Override +public void processCommand(ICommandSender sender, String[] args) throws CommandException { + // Make sure to check the array length before checking an argument + if (args.length == 1 && args[0].equals("confirm")) { + LogManager.getLogger("CrashCommand").info("Intentionally crashing the Game!"); + FMLCommonHandler.instance().exitJava(1, false); + } else { + sender.addChatMessage(new ChatComponentText("§aAre you sure you want to crash the game? Click to confirm!") + .setChatStyle(new ChatStyle() + .setChatClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/crashme confirm")))); + } +} +``` + +!!! info + Because `sender` is always the current player, you can also use + ```java + Minecraft.getMinecraft().thePlayer.addChatMessage(/* ... */); + ``` + +Minecraft uses `IChatComponent`s in chat (and a few other places). You can make those by calling `:::java new ChatComponentText("")`. In there you can use format codes like `§a`. If you want, you can also use `:::java EnumChatFormatting.GREEN.toString()` instead of `§a`. You can change the chat style of a `ChatComponentText` in order to give it hover or click effects. + + +!!! warning + You might be tempted to open a gui from your command like this: + ```java + @Override + public void processCommand(ICommandSender sender, String[] args) throws CommandException { + Minecraft.getMinecraft().displayGuiScreen(new MyGuiScreen()); + } + ``` + This will not work, since your command gets executed from the chat gui and sending a chat line schedules the chat gui to be closed in the same tick (accidentally closing your gui instead). + + In order to make this work, you need to instead wait a tick and then open your gui. You can do this by having a tick event handler in your main mod class like this: + ```java + // In your main mod class + public static GuiScreen screenToOpenNextTick = null; + + @SubscribeEvent + public void onTick(TickEvent.ClientTickEvent event) { + if (event.phase == TickEvent.Phase.END) return; + if (screenToOpenNextTick != null) { + Minecraft.getMinecraft().displayGuiScreen(screenToOpenNextTick); + screenToOpenNextTick = null; + } + } + + // In your command class: + @Override + public void processCommand(ICommandSender sender, String[] args) throws CommandException { + ExampleMod.screenToOpenNextTick = new MyGuiScreen(); + } + ``` + + See [Events](/development/events) for more info on how to set up event handlers. + +## Registering your command + +After all this work your command still just will not run. This is because the final step of client commands is still missing. You need to register your command. You typically do this in the `FMLInitializationEvent`: + + +```java +@Mod.EventHandler +public void init(FMLInitializationEvent event) { + ClientCommandHandler.instance.registerCommand(new CrashCommand()); +} +``` + + + + diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..152ae62 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,111 @@ +# Events in Forge + +Forge uses events to allow mods to communicate with Minecraft and each other. Most of the events you will need to use come from Forge, but you can also create your own events if you need more. + +## Subscribing to events + +If you are interested in an event you need to create an event handler. For this first create a method that has the `@SubscribeEvent` annotation, is `public`, return `void` and takes an event as an argument. The type of the event argument is what decides which events your method receives. You can also only have one argument on an event handler. + +```java +public class MyEventHandlerClass { + int chatCount = 0; + @SubscribeEvent + public void onChat(ClientChatReceivedEvent event) { + chatCount++; + System.out.println("Chats received total: " + chatCount); + } +} +``` + +This on it's own will not do anything yet. You must also register the event handler. To do that you register it on the corresponding event bus. For almost everything you will do, you need the `MinecraftForge.EVENT_BUS` (yes, even your own custom events should use this event bus). + + +```java +@Mod(modid = "examplemod", useMetadata = true) +public class ExampleMod { + @Mod.EventHandler + public void init(FMLInitializationEvent event) { + MinecraftForge.EVENT_BUS.register(new MyEventHandlerClass()); + } +} +``` + +## Cancelling Events + +Forge Events can be cancelled. What exactly that means depends on the event, but it usually stops the action the event indicates from happening. + +```java +@SubscribeEvent +public void onChat(ClientChatReceivedEvent event) { + // No more talking about cheese + if (event.message.getFormattedText().contains("cheese")) + event.setCanceled(true); +} +``` + +Not all events can be cancelled. Check the event class in the decompilation for the `@Cancellable` annotation. + + +## Custom Events + +!!! note + This is an advanced topic that most mod developers don't need to worry about. + +Forge also allows you to create custom events. Each event needs to have it's own class extending `Event` (transitively or not). (Make sure you extend `net.minecraftforge.fml.common.eventhandler.Event`). + +```java +// If you don't want your event to be cancellable, remove this annotation +@Cancelable +public class CheeseEvent extends Event { + public final int totalCheeseCount; + + public CheeseEvent(int totalCheeseCount) { + this.totalCheeseCount = totalCheeseCount; + } +} +``` + +That's it, you are done. You have a custom event! + +I'm kidding of course. The next step is actually using your event. For now, let's put our own custom event inside the forge chat event: + +```java +int cheeseCount = 0; + +@SubscribeEvent +public void onChat(ClientChatReceivedEvent event) { + if (event.message.getFormattedText().contains("cheese")) { + CheeseEvent cheeseEvent = new CheeseEvent(++cheeseCount); + MinecraftForge.EVENT_BUS.post(cheeseEvent); + } +} +``` + +And now we are done. Unless you want your event to be cancellable. We also need to add code to handle cancelled events (if you made your event `@Cancelable`). What that cancelling does is up to you, but in our example let's just cancel the original chat message event (hiding that chat message): + +```java +@SubscribeEvent +public void onChat(ClientChatReceivedEvent event) { + if (event.message.getFormattedText().contains("cheese")) { + CheeseEvent cheeseEvent = new CheeseEvent(++cheeseCount); + MinecraftForge.EVENT_BUS.post(cheeseEvent); + if (cheeseEvent.isCanceled()) { + event.setCanceled(true); + } + } +} +``` + +You can now subscribe to your custom event like you would to any other event: + +```java +@SubscribeEvent +public void onCheese(CheeseEvent event) { + if (event.totalCheeseCount > 10) { + // Only 10 cheese messages are allowed per restart + event.setCanceled(true); + } +} +``` + + diff --git a/docs/img/background-screen.png b/docs/img/background-screen.png new file mode 100644 index 0000000..2103db0 Binary files /dev/null and b/docs/img/background-screen.png differ diff --git a/docs/img/background.png b/docs/img/background.png new file mode 100644 index 0000000..ca51491 Binary files /dev/null and b/docs/img/background.png differ diff --git a/docs/img/color-selector-screen.png b/docs/img/color-selector-screen.png new file mode 100644 index 0000000..9956324 Binary files /dev/null and b/docs/img/color-selector-screen.png differ diff --git a/docs/img/text-alignment.png b/docs/img/text-alignment.png new file mode 100644 index 0000000..c3a9b83 Binary files /dev/null and b/docs/img/text-alignment.png differ diff --git a/docs/img/uvs.jpg b/docs/img/uvs.jpg new file mode 100644 index 0000000..d9027e2 Binary files /dev/null and b/docs/img/uvs.jpg differ diff --git a/docs/index.md b/docs/index.md index 6fc0cfa..2ae6156 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,10 +1,10 @@ # Welcome to nea89s Modding Wiki -This wiki is aimed at mod developers for Minecraft 1.8.9 and those that want to be. More specifically, this wiki is here for client mods for Hypixels [SkyBlock](https://wiki.hypixel.net/Main_Page). +This wiki is aimed at mod developers for Minecraft 1.8.9 and those who want to be. More specifically, this wiki is here for client mods for Hypixels [SkyBlock](https://wiki.hypixel.net/Main_Page). First a word of warning: Modding isn't always for the faint of heart. Mojang doesn't really intend for people to mod their game, so there isn't exactly a lot of documentation laying around. What little documentation you will find is probably for newer versions and most mod dev forums either can't or don't want to help you with an [8 year old minecraft version](https://howoldisminecraft189.today/). But while starting with a mod as your first programming project isn't great and it helps to know your ways around Java as well, if you are passionate then I believe you can do it! -Despite all that, there is still a community around modding SkyBlock. You don't have to go into this alone, you can always join up with an existing modding team, which also helps beating the ratting allegations. Check out [existing mods and their communities][mod-list]! Almost everything is more fun with other people, and learning from other people will never be replaced by a cold, heartless wiki such as this one. +Despite all that, there is still a community around modding for SkyBlock. You don't have to go into this alone, you can always join up with an existing modding team, which also helps beating the ratting allegations. Check out [existing mods and their communities][mod-list]! Almost everything is more fun with other people, and learning from other people will never be replaced by a cold, heartless wiki such as this one. If you get all that and you are still in, then let's [start with the IDE set up](ide-setup.md). diff --git a/docs/mixins/accessors.md b/docs/mixins/accessors.md new file mode 100644 index 0000000..f9ad7b8 --- /dev/null +++ b/docs/mixins/accessors.md @@ -0,0 +1,45 @@ +# Accessor Mixins + +> a/k/a Reverse Patch + +Let's start with the easiest form of Mixins — Accessors. Accessors allow you to access functions that are otherwise private in a Minecraft class. You can also do that using reflection, but you might notice that your reflection call will not easily work in both devenv and a live env. This is because the method names are different in a devenv compared to a normal Forge installation. You can still specify both names and just look through all the names using reflection, but Accessor Mixins are a lot easier to use, with less accidental pitfalls, and better performance. + +```java +// This Mixin targets the Minecraft class +@Mixin(Minecraft.class) +public interface AccessorMinecraft { + + // Getter for the field theIntegratedServer + // Notice the _mymodid at the end. + @Accessor("theIntegratedServer") + IntegratedServer getIntegratedServe_mymodid(); + + // Setter for serverPort + @Accessor("serverPort") + void setServerPort_mymodid(int port); + + // Invoker for rightClickMouse. + @Invoker("rightClickMouse") + void rightClickMouse_mymodid(); +} +``` + +First, notice that we need to use an `interface`. Most mixins are `class`es. Accessors are the exception, since we don't want to actually put any code into the `Minecraft` class. Accessor mixins can also not be mixed with other mixin styles, but since you should have multiple mixins even for the same class for different things anyway, this shouldn't be an issue. + +Next we put the `@Mixin` annotation on our Accessor to let it known which class we want to inject into. + +Then for a field we use the `@Accessor` annotation, with the name of the field we want to access. Please give all your mixin methods a `_mymodid` indicator, to avoid name collissions with other mods. + +For a setter, the method returns void and takes one argument of the type of the field you are targetting. For a getter, the method returns the type of the field you are targeting and takes no arguments. + +For an method invoker, you copy the method signature you want to call, but rename it to something unique with a `_mymodid` postfix. + +Now if you want to use those methods, you can simply cast any instance of your targeted class to your accessor mixin class: + +```java +Minecraft mc = Minecraft.getMinecraft(); +AccessorMinecraft accessorMc = (AccessorMinecraft) mc; +accessorMc.rightClickMouse_mymodid(); +``` + +If you get a class cast exception here, it means your mixin was not applied. This can happen if you forgot to register your mixin, or if your mixin contains errors. diff --git a/docs/mixins/adding-fields.md b/docs/mixins/adding-fields.md new file mode 100644 index 0000000..c3533e9 --- /dev/null +++ b/docs/mixins/adding-fields.md @@ -0,0 +1,50 @@ +# Adding new fields and methods + +The next step up is injecting fields and methods into a class. This allows you to store additional state in objects, or can serve as an alternative to accessors for more complex operations that need to access private state of a class. + +```java +@Mixin(EntityArmorStand.class) +public class InjectCustomField { + Color colorOverride_mymodid; + + public void setColorOverride_mymodid(Color color) { + colorOverride_mymodid = color; + } + + public Color getColorOverride_mymodid() { + return colorOverride_mymodid; + } +} +``` + +This mixin is a `class`, like all mixin (except for accessors) are. You can make the class abstract if you want. + +First we add a new field (of course with modid postfix) into every armor stand. + +Then we also add a getter and a setter method for that field. + +Right now we run into a problem. We can't access mixin classes directly, so we cannot simply cast the `EntityArmorStand` into a `InjectCustomField`. Instead we create an interface (inside of our regular code, not inside of the mixin package) and implement that interface in our mixin class. You can also implement other interfaces this way, not just your own. + +```java +// Inside our regular code. Not in the mixin package +public interface ColorFieldAccessor { + void setColorOverride_mymodid(Color color); + Color getColorOverride_mymodid(); +} + +// And the updated mixin +@Mixin(EntityArmorStand.class) +public class InjectCustomField implement ColorFieldAccessor { + Color colorOverride_mymodid; + + @Override + public void setColorOverride_mymodid(Color color) { + colorOverride_mymodid = color; + } + + @Override + public Color getColorOverride_mymodid() { + return colorOverride_mymodid; + } +} +``` diff --git a/docs/mixins/advanced-injects.md b/docs/mixins/advanced-injects.md new file mode 100644 index 0000000..39f74e0 --- /dev/null +++ b/docs/mixins/advanced-injects.md @@ -0,0 +1,80 @@ +# Advanced Injects + +So you wanna learn how to *really* use Injects? It is gonna be a tough road, and I won't lead you all the way there (mostly because eventually there are diminishing returns on a tutorial like this), but eventually most SkyBlock devs fall down the rabbit hole. + +This will be pretty dry compared to the other mixin tutorials, so feel free to skip reading this and just use this as a glossary. + +## Remapping + +Let's start with names. Names are important. If you call a method with the wrong name, You get a crash at best, and at worse you cause undefined behavior. But, most methods go by unpronounceable names like `v` or `method_12934`. This is because Mojang obfuscated Minecraft, replacing every class name, every method name, etc. with a randomly generated short name to prevent people from reverse engineering it (which has the nice side effect of bit of a smaller binary). Now if we develop mods, we don't want to work with names like those. So we use mappings. Those are long lists telling us which obfuscated method name corresponds to a readable method name. In modern versions you have [yarn](https://github.com/FabricMC/yarn/) (which is a community project), as well as official names from [Mojang](https://nea.moe/minecraft.html) themselves, but in older versions, we just have MCP. + +Let's go through the process of how your normal Forge mod gets compiled: + + - Download Minecraft (obfuscated by mojang) + - Actually download another copy of Minecraft (the server, also obfuscated) + - Merge the two JARs into one, so you can reference both server and client classes from the same mod + - Apply the MCP mappings to the JAR, turning Mojangs names into readable ones. + - Apply some patches to the JAR, to inject Forge events and custom registries and such. + - the order of those first 5 steps isn't always the same. minecraft version and liveenv/devenv differences can rearrange them sometimes + - Now you compile your mod source against this new Minecraft JAR (as well as some extra libraries) + - Forge in a live environment uses an intermediary between the completely obfuscated and the completely readable names, so now we need to turn our readable names back into intermediary ones + - For this, Forge goes through your generated JAR and applies the mappings from earlier, but in reverse + +This process has it's drawbacks. Especially that last step isn't perfect, and not everything you do will be remapped (and sometimes that is desired). + +Let's look at some examples: + + +```java +public void myFunc() throws Throwable { + ItemStack itemStack = new ItemStack(/* ... */); + itemStack.getDisplayName(); + ItemStack.class.getMethod("getDisplayName").invoke(itemStack); + System.out.println("net.minecraft.item.ItemStack"); + System.out.println("ItemStack"); +} +``` + +Now the forge remapper will take that code and get you something like this in the actual compiled mod: + +```java +public void myFunc() throws Throwable { + azq itemStack = new azq(/* ... */); + itemStack.b(); + azq.class.getMethod("getDisplayName").invoke(itemStack); + System.out.println("net.minecraft.item.ItemStack"); + System.out.println("ItemStack"); +} +``` + +There are a few things that work and a few things don't in this snippet. + +The normal usage of `ItemStack` gets correctly replaced with `azq` the correct obfuscated name (well, in reality the obfuscated name would be a different one, but the basic idea holds) and the `getDisplayName` call gets replaced with `b`. + +But the reflection didn't work out so great. While the `.class` literal did get remapped, the `getMethod` argument didn't. And if we used `Class.forName` that would also not get remapped. This is because those values are just strings that just so happen to have the same name as a class or method. For this simple case, you might think we could just do some flow analysis and remap those values, but for more complicated cases (maybe the method name gets passed as an argument, or stored in a variable) the flow analysis is not that clear. Those cases *could* be covered, but doing so would lead to a lot of inconsistencies around the edges of our flow analysis. A simple refactor could lead to your code not being remapped correctly. In that light it is better to just not remap strings at all. + +The `println` is not changed either, but most likely those debug prints are not meant to change. If you later get an error relating to this method and you search for "ItemStack", you want to find those log entries in your log. So in this case the "failed" remap is actually the correct behaviour. + +Now given all this information, let's see how mixins handle remaps. + +## Refmaps + +Refmaps are mixins way around the forge compilation step. Mixins uses a lot of string identifiers. From method names in `@Inject(method = "")` to method descriptors in `@At(target = "", value = "INVOKE")`, to many more. All those strings are not recognized by Forge as something to be remapped, and even if Forge did remapping on strings, those strings are often in complicated formats that are wildly different from how Forge expects them. Because of this mixins instead use their own extra compilation step to remap all that information. + +The mixin refmap strategly looks like this: + + + - Compile against the deobfuscated (readable name) Minecraft JAR, like the normal mod. + - Let Forge take care of all the real java code (the method bodies, method arguments and return types, class references in annotations) + - Afterwards, take a look at all mixin annotations and resolve the things they refer to using the readable names. + - Then, since we are still in the development environment where those mappings are available, create a JSON file that contains all mappings relevant to all the mixin annotations. + - Mixin doesn't just ship all the mappings because they are quite large and 99% not needed. + - This JSON file is called the "refmap" + - Later, at runtime, when the Mixin class transformer parses the annotations to apply class transformations it reads the refmap and resolves annotation arguments using those names. + +You might run into a problem sometimes when referring to a non remapped method however. Not all methods in Minecrafts code are obfuscated. Some need to keep their original name in order to interact with other Java code. For example the `equals` method of an object needs to always be called `equals`. Obfuscating that method breaks comparisons used by a lot of Java standard library functions. When Mixin encounters those unobfuscated names during the refmap collection step, it notices the lack of a remapped name. This could mean that something is just named the same, but it could also mean that there is an error (the developmer mistyped a name). If you want to inform mixin that you are aware of a lacking mapping, you can do so by specifying `remap = false` on that annotation. It only applies to that specific annotation, so you might need to apply it to your `@Inject` and your `@At` separately. + + + + + diff --git a/docs/mixins/index.md b/docs/mixins/index.md new file mode 100644 index 0000000..bf4aa67 --- /dev/null +++ b/docs/mixins/index.md @@ -0,0 +1,79 @@ +# Mixins + +Mixins allow you to change Minecraft code. This is massively powerful, but you need to be very careful when using them, especially when considering to work together with other mods. + +!!! info + The [MinecraftDev](https://mcdev.io/) plugin is pretty much non negotiable when coding Mixins. It enables auto completion, shows errors when your mixins are wrong in your IDE and allows you to directly navigate to the code you are changing. + It also has some other functions that allow for easier Minecraft development, but most of the functionality is aimed at higher versions. + +> Please forgive the the nonsensical examples. I try to make the examples as simple as possible. [Check](https://github.com/NotEnoughUpdates/NotEnoughUpdates/tree/master/src/main/java/io/github/moulberry/notenoughupdates/mixins) [out](https://github.com/hannibal002/SkyHanni/tree/beta/src/main/java/at/hannibal2/skyhanni/mixins/transformers) [some](https://github.com/Skytils/SkytilsMod/tree/1.x/src/main/java/gg/skytils/skytilsmod/mixins/transformers) [open](https://github.com/inglettronald/DulkirMod/tree/master/src/main/java/dulkirmod/mixins) source mods to check out some real world mixins. + +## Layout + +Mixins need to be in their own package. You should have a dedicated mixin package in the template already. You can have multiple subpackages, but your normal code and your Mixin code need to be separate. This is because Mixins are instructions for how to change the program, rather than actual program code itself. Mixins also need to be registered in your `mixin.example.json`. In there you only need to put the class name, not including the mixin package. Mixins also need to be written in Java, not in Kotlin. + +```json +{ + "package": "${mixinGroup}", + "refmap": "mixins.${modid}.refmap.json", + "minVersion": "0.7", + "compatibilityLevel": "JAVA_8", + "mixins": [ + "MixinGuiMainMenu", + "subpackage.MixinSomeOtherClass" + ] +} +``` + +You can also have multiple mixins for the same Minecraft class. + +## Mixin Use Cases + +I recommend you start learning with accessor mixins, since those are the easiest, and go down the list from there. + + - [Accessors](./accessors.md) + - [Adding Fields and Methods](./adding-fields.md) + - [Simple Injects](./simple-injects.md) + +## Compatibility + +### Modid postfix + +In order for your mod to be compatible with other mods it is *highly* recommend (if not borderline mandatory) to prefix or postfix all of your methods with your modid: + +```java +public void someMixinMethod_mymodid() {} +// or +public void someMixinMethod$mymodid() {} +``` + +There are some exceptions for `@Inject`s, but in general it doesn't hurt to just add the postfix. + +### Non destructive mixins + +When mixing into a class you would generally want that if another mod the exact same mixin, that both of your mixins would work. Especially if your mixin only works sometimes. + +I.e. if you want a mixin to color mobs, and your mod decides not to color a mob, another mod should be able to use the exact same mixin (just in their mod) to color those mobs. + +There are some general ground rules for achieving this behaviour: + + - Only use `cir.setReturnValue()` or `ci.cancel()` if your mod decides to act on something. The default action should be to pass through to the next mixin or vanilla by doing nothing. + - Don't use `@Redirect`. Only one mixin can ever use a `@Redirect` on the same call. Only one redirect will ever work, even if your mod does nothing different with a given method call. + - Don't use `@Overwrite` (and don't overwrite without the annotation either, lol). Only one overwrite will ever work, even if your mod does nothing different with a given method call. + +Of course you will have to break those rules from time to time. But before you do, think twice if you need to. And if you do, maybe consider exposing some sort of API for other mods to hook into your code? + +## Troubleshooting + +The first step in troubleshooting mixins is to enable `-Dmixin.debug=true` in your run configurations jvm arguments. This will print out all the Mixins as they are applied and show you exactly what is wrong with each mixin, and why it wasn't applied. + +Another common issue is to forget to register a mixin in the `mixins.modid.json` + +You can also get exceptions when trying to load a mixin class directly. Accessing any mixin class except for an accessor from non mixin code will crash your game. If you want to call a method inside a mixin, have that mixin implement an interface instead. + + + + + + + diff --git a/docs/mixins/simple-injects.md b/docs/mixins/simple-injects.md new file mode 100644 index 0000000..eeea65e --- /dev/null +++ b/docs/mixins/simple-injects.md @@ -0,0 +1,133 @@ +# Simple Injects + +Let's get into method modifications. The real interesting part of mixins. Hopefully you know the basics from the first two mixin tutorials by now, because now we get into a whole another layer of complexity. + +Now we will modify an existing method in Minecrafts code. This will allow us to react to changes in Minecrafts state. This is how almost all custom [events](../events.md) are done, but we are of course not limited to just events that observe state changes. Using method modifying mixins we can change almost any behaviour in Minecrafts code. + +!!! note + This is the simple tutorial, I will tell you *how* to use `@Inject`s and co, but I won't tell you the *why*. Check out the [advanced tutorial](./advanced-injects.md) for that. + +## The easiest of the easiest + +Let's start with probably the easiest `@Inject` out there. The HEAD inject. This mixin will inject whatever code you have inside your method at the start of the method you target. + +```java +@Mixin(PlayerControllerMP.class) +public class RightClickWithItemEvent { + + @Inject(method = "sendUseItem", at = @At("HEAD")) + private void onSendUseItem_mymod(EntityPlayer playerIn, World worldIn, ItemStack itemStackIn, CallbackInfoReturnable cir) { + MinecraftForge.EVENT_BUS.post(new SendUseItemEvent(playerIn, worldIn, itemStackIn)); + } +} +``` + +First we want to inject into the `PlayerControllerMP` class. + +We create an `@Inject`. This tells us in which method we want to inject (`sendUseItem`) and where in that method (`HEAD`, meaning the very top of the method). + +The actual method signature for an inject is always to return a `void`. You can make them `private` or `public`. The arguments are the same arguments as the method you want to inject into, as well as a `CallbackInfo`. + +For a method returning void, you just use a `CallbackInfo`, and if the method returns something, you use `CallbackInfoReturnable`. + +Your method will now be called every time the `sendUseItem` is called with the arguments to that method and the `CallbackInfo`. + +!!! important + Your method will be *called* at the beginning of the injected into method like this: + + ```java + public boolean sendUseItem(EntityPlayer playerIn, World worldIn, ItemStack itemStackIn) { + onSendUseItem_mymod(playerIn, worldIn, itemStackIn, new CallbackInfo(/* ... */)); + // All the other code that is normally in the method + } + ``` + + This means returning from your method will just continue as normal. See [cancelling](#cancelling) for info on how to return from the outer method. + +## At a method call + +Let's take this example method: + +```java +public void methodA() { + // ... +} + +public void methodB() { + System.out.println("Here 1"); + methodA(); + // We want to inject our method call right here. + System.out.println("Here 2"); +} +``` + +We can inject ourselves into `methodB` as well. It is *just* a bit more complicated than the `HEAD` inject. + +```java +@Inject(method = "methodB", at = @At(target = "Lnet/some/Class;methodA()V", value = "INVOKE")) +private void onMethodBJustCalledMethodA(CallbackInfo ci) { +} +``` + +> **HUUUUH, where does that come from???** + +Don't worry! I won't explain you how to understand these `target`s in this tutorial, but you also don't need to understand that `target`. Instead you can simply use the Minecraft Development IntelliJ Plugin to help you. Simply type `@At(value = "INVOKE", target = "")`, place your cursor inside of the target and use auto completion (Ctrl + Space) and the plugin will recommend you a bunch of method calls. Find whichever seems right to you and press enter. You can now (also thanks to the plugin) Ctrl click on the `target` string, which will take you to the decompiled code exactly to where that target will inject. + +## Ordinals + +Let's take the method injection example from before and change it a bit: + +```java +public void methodA() { + // ... +} + +public void methodB() { + System.out.println("Here 1"); + if (Math.random() < 0.4) + methodA(); + System.out.println("Here 2"); + methodA(); + // We want to inject our method call right here. + System.out.println("Here 3"); +} +``` + +We can't simply use the same `@Inject` from before, since by default a `INVOKE` inject will inject just after *every* method call. Here, we can use the `ordinal` classifier to specify which method call we want to use. Keep in mind this is about where to place our injection, so many method calls in a loop will not increment the ordinal, only unique code locations that call the function will increase the ordinal. Keep in mind: we are programmers, we start counting with `0`. + +```java +@Inject(method = "methodB", at = @At(target = "Lnet/some/Class;methodA()V", value = "INVOKE", ordinal = 1)) +private void onMethodBJustCalledMethodA(CallbackInfo ci) { +} +``` + +## Cancelling + +Cancelling a method means you return from the method you are injected to as soon as your injector method is done. In order to be able to use the cancelling methods, you need to mark your injection as cancellable. + +```java +@Inject(method = "syncCurrentPlayItem", at = @At("HEAD"), cancellable = true) +private void onSyncCurrentPlayItem_mymod(CallbackInfo ci) { + System.out.println("This code will be executed"); + if (Math.random() < 0.5) + ci.cancel(); + System.out.println("This code will *also* be executed"); + // As soon as this method returns, the outer method will see that it was cancelled and *also* return +} + +@Inject(method = "isHittingPosition", at = @At("HEAD"), cancellable = true) +private void onIsHittingPosition_mymod(BlockPos pos, CallbackInfoReturnable cir) { + cir.setReturnValue(true); +} +``` + +For `void` methods you need to use `callbackInfo.cancel()`. For all other methods you need to use `callbackInfoReturnable.setReturnValue(returnValue)`. + + +!!! important + Cancelling a `CallbackInfo` will only have an effect as soon as you return from your injector method. + The rest of your method will run as normal. + + + + diff --git a/docs/screens.md b/docs/screens.md new file mode 100644 index 0000000..8098d67 --- /dev/null +++ b/docs/screens.md @@ -0,0 +1,298 @@ +# Screens and You + +Creating a custom screen manually is quite a big effort. Instead you can consider using an existing GUI library: + + - [MoulConfig](https://notenoughupdates.org/MoulConfig/) is a config library with some features for custom GUIs based on NEUs GUIs. + - [Elementa](https://github.com/EssentialGG/Elementa) is a gui library made by sk1er. It mainly targets Kotlin, an alternative programming language to Java, but can in theory also be used from Java. + - [Vigilance](https://github.com/EssentialGG/Vigilance) is fully automated Elementa for config GUIs only. + - [OneConfig](https://docs.polyfrost.org/oneconfig/) is a config library made by Polyfrost. + - or just do it yourself. Writing a gui is not the easiest thing in modding, but nothing allows you more customization. + +## Basic Scaffold + +A basic gui screen has 3 methods: + +```java +public class MyGuiScreen extends GuiScreen { + @Override + public void drawScreen(int mouseX, int mouseY, float partialTicks) { + drawDefaultBackground(); + super.drawScreen(mouseX, mouseY, partialTicks); + } + + @Override + protected void keyTyped(char typedChar, int keyCode) throws IOException { + super.keyTyped(typedChar, keyCode); + } + + @Override + protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException { + super.mouseClicked(mouseX, mouseY, mouseButton); + } +} +``` + +`drawScreen` is called every frame and is used to render things onto the screen. Note that you *first* call `drawDefaultBackground()` (which tints the background dark) and then call `super.drawScreen()` (which renders widgets on your screen). + +`keyTyped` is called whenever a key is pressed. You can check `typedChar` to see the character they typed, or you can check `keyCode` if you want to know the key they pressed. For example a key like F7 would not have a `typedChar`, but would have the `keyCode == Keyboard.KEY_F7`. We also call `super.keyTyped()` here so that the standard Escape to close works. + +`mouseClicked` is called whenever the mouse is clicked in your screen. The `mouseButton` sadly doesn't have a vanilla class with namese for them like `Keyboard`, but you can use these constants instead: + +```java +public static final int MOUSE_LEFT = 0; +public static final int MOUSE_RIGHT = 1; +public static final int MOUSE_MIDDLE = 2; +public static final int MOUSE_BACKWARD = 3; +public static final int MOUSE_FORWARD = 4; +``` + +You can also always access the `width` and `height` fields to get the screen width and height to layout your components. + +## Adding buttons + +Vanilla has a system for buttons already built in, which i am using for a simple color selector: + +```java +int lastClickedButton = 0; + +@Override +public void initGui() { + super.initGui(); + // Add buttons to the gui list during gui initialization + this.buttonList.add(new GuiButton(0, width / 2 - 55, height / 2 - 10, 30, 20, "§cRED")); + this.buttonList.add(new GuiButton(1, width / 2 - 15, height / 2 - 10, 30, 20, "§9BLUE")); + this.buttonList.add(new GuiButton(2, width / 2 + 25, height / 2 - 10, 30, 20, "§2GREEN")); +} + +@Override +protected void actionPerformed(GuiButton button) throws IOException { + // When a button is clicked saved that last id (or do something else based on the id) + // You could change a setting here for example + lastClickedButton = button.id; +} + +@Override +public void drawScreen(int mouseX, int mouseY, float partialTicks) { + // Draw the background tint + drawDefaultBackground(); + + // Find the last selected color + int color = 0; + if (lastClickedButton == 0) { + color = 0xFFFF0000; + } else if (lastClickedButton == 1) { + color = 0xFF0000FF; + } else if (lastClickedButton == 2) { + color = 0xFF00FF00; + } + + // Draw a colorful rectangle + drawGradientRect(width / 2 - 65, height / 2 - 20, width / 2 + 65, height / 2 + 20, color, color); + + // Draw buttons + super.drawScreen(mouseX, mouseY, partialTicks); +} + +``` + +![A screen with 3 buttons and blue background. The button labels are blue, red and green](img/color-selector-screen.png) + +## Rendering text and images + +Instead of using built in buttons and basic rectangles, you can also render more complex things. + +### Rendering text + +You can easily use Minecrafts built in font renderer to render any text you like. + +```java +@Override +public void drawScreen(int mouseX, int mouseY, float partialTicks) { + // Draw tinted background + drawDefaultBackground(); + + // Draw an outline rectangle + drawGradientRect(width / 2 - 100, height / 2 - 20, width / 2 + 100, height / 2 + 20, 0xFF808080, 0xFF808080); + + FontRenderer fr = Minecraft.getMinecraft().fontRendererObj; + String text = "Hello, World!"; + int textWidth = fr.getStringWidth(text); + + // Draw a string left aligned + fr.drawString(text, width / 2 - 95, height / 2 - 18, -1); + + // Draw a string center aligned + fr.drawString(text, width / 2 - textWidth / 2, height / 2 - 8, -1); + + // Draw a string right aligned + fr.drawString(text, width / 2 + 95 - textWidth, height / 2 + 2, -1); +} +``` + +![A screen with the text "Hello, World!" 3 times. Once left aligned, once center aligned, once right aligned](img/text-alignment.png) + +### Rendering images + +Images in Minecraft are rendered from the assets folder. In your project, you should have a folder called `src/main/resources`. In that folder you create two more folders called `assets//`. That is your asset root. In here you can put any file you want and load it into Minecraft. You probably want your textures to be in a folder like `textures/gui` *inside* your asset root however (`src/main/resources/assets//textures/gui/mytexture.png`). + +For images you probably want to use png files. + +For this tutorial you can use [this background](img/background.png). + + +```java +@Override +public void drawScreen(int mouseX, int mouseY, float partialTicks) { + // Draw tinted background + drawDefaultBackground(); + + Minecraft minecraft = Minecraft.getMinecraft(); + + // First we need to bind the texture + minecraft.getTextureManager().bindTexture(new ResourceLocation("examplemod"/* or your modid */, "textures/gui/background.png")); + + // Render from your texture. + drawModalRectWithCustomSizedTexture( + // The first two arguments are the position on the screen + width / 2 - 100, height / 2 - 20, + // The next arguments are the starting u and v + 0, 0, + // The next arguments are the size on the screen + 200, 40, + // The last two arguments are the size of the texture + 200, 40 + ); + + FontRenderer fr = minecraft.fontRendererObj; + String text = "Hello, World!"; + int textWidth = fr.getStringWidth(text); + + // Draw a string left aligned + fr.drawString(text, width / 2 - 95, height / 2 - 15, 0xFF000000); + + // Draw a string center aligned + fr.drawString(text, width / 2 - textWidth / 2, height / 2 - 5, 0xFF000000); + + // Draw a string right aligned + fr.drawString(text, width / 2 + 95 - textWidth, height / 2 + 5, 0xFF000000); +} +``` + +#### UVs + +Texture rendering in OpenGL (which is what Minecraft uses) uses UVs for reading from a texture. Instead of always rendering an entire texture, you can render only parts of a texture. This allows you to reuse a texture, without rebinding it, which can be beneficial for performance. For now I wouldn't worry about optimizing like that too much, but you'll still need to use UVs anyway. + +The U component goes along the x axis of the image and starts with 0 at the left and ends with 1 at the right. The V component goes along the y axis of the image and starts with 0 at the top and ends with 1 at the bottom. This is specific to OpenGL, other game engines might have different UV coordinate spaces. + +![UV graph](img/uvs.jpg) + +To calculate the actual uv you want to start with in game, just divide the pixel coordinate by the texture size: + +```java +float u = 16 / 64; // start at x pixel coordinate 16 with a 64 wide image +``` + + + +## GlStateManager + +`GlStateManager` is a class that changes the behaviour of all other rendering calls. + +### Transformations + +`GlStateManager` has a so called "matrix stack" which stores a set of transformations that get applied to all render calls you do. Only the top layer of the matrix stack affects your render calls. + +#### Translations + +Translations move things around. `translate(10, 0, 0)` would cause all future render calls to instead render 10 pixels to the right. You may notice that `translate` takes 3 arguments. This is because the `z` direction is also translated. This is useful in 3D, but can be used in 2D GUI rendering as well to move things in front of other things. In a GUI a greater z value means that something renders in front of something that has a lower z. + +You can use this to render a tooltip for example. By default later rendering calls in your method would render on top of the tooltip, but if you first translate to a high z value and then back to normal after your tooltip rendering, the other method calls won't render on top of the tooltip. + +#### Scaling + +Scalings, well, *scale* things. This means a `scale(2, 2, 1)` call would render everything after twice as big. But you need to be careful. Everything is twice as big, including the coordinates at which you render. + +```java +GlStateManager.scale(2, 2, 1); + +fontRenderer.drawString("Hello, World!", 10, 10, -1); +``` + +This would normally render at `(10, 10)`. But since everything is scaled two times, it actually renders at `(20, 20)` and twice as big. To circumvent that you can instead first translate, then scale, and then render at `(0, 0)`. + +```java +GlStateManager.translate(10, 10, 0); +GlStateManager.scale(2, 2, 1); + +fontRenderer.drawString("Hello, World!", 0, 0, -1); +``` + +Alternatively you can do the math and divide all coordinates by your current scale factor. + +!!! warning + Please always use `1` as a scale factor for the z direction. If you do not do this, a lot of rendering calls will break and parts of your GUI might just not render at all or in the wrong order. You can use other non-zero scale factors for z like `2` sometimes, but in almost all cases that is the **wrong behaviour** and you should instead use the scale factor `1`. + +#### Stack Manipulation + +I mentioned earlier that the GlStateManager has a matrix stack. Your transformations and render calls only ever use the topmost matrix on that stack. Those other layers have a purpose however. Since you always want to hand back the GlStateManager in the same state you got it (otherwise all of Minecraft renders 10 pixels to the side. *oops*) you might be tempted to manually undo all your `translate` and `scale` calls. While this is doable, a much easier way is to instead use the matrix stack. + +You can push a new matrix to the stack by using `pushMatrix`. This copies the current top matrix (with all the transformations applied by earlier code) and makes that new matrix the top matrix. Then after your code is done you can just call `popMatrix` and that top matrix is discarded and the old matrix with all the old transformations is used for the rest of the code. Just put your rendering and transformation calls inside of those two method calls and all should work out. (Of course inside of the `pushMatrix` `popMatrix` environment transformations still only apply to code after the transformation is applied.) + +```java +GlStateManager.pushMatrix(); +GlStateManager.translate(Math.random() * 100, Math.random() * -30, 200000); +// more wacky transformation calls here +// then comes your rendering code +fr.drawString("Hi", 0, 0, -1); +GlStateManager.popMatrix(); +// And here everything is normal again +``` + + +#### Rotation + +Rarely you might also want to rotate things. Rotation works a bit differently than how you might expect it. A common way of doing rotation is with euler angles, so basically just rotation around x, y and z as 3 values. This has a lot of drawbacks, but is quite easy to visualize and is commonly used in 3D modeling software. + +Minecraft instead uses rotations around a vector. This means you choose the axis you want to rotate around, and then you choose the angle. If you want to rotate around x, y and z after one another, you need to make 3 rotate calls this way. + +Remember, like with the other transformations, the Z direction points out of the screen towards the "front". + +```java +GlStateManager.pushMatrix(); +GlStateManager.translate(width / 2, height / 2 - 5 + fr.FONT_HEIGHT / 2, 0); +GlStateManager.rotate((float) ((System.currentTimeMillis() / 200.0) % (360)), 0, 0, 1); +fr.drawString(text, -textWidth / 2, -fr.FONT_HEIGHT / 2, 0xFF000000); +GlStateManager.popMatrix(); +``` + +### Attributes + +In addition to transformations you can also change "attributes" about the render calls. These can be occasionally useful, but are a bit more complicated. + +#### Stack Manipulation + +Like the matrix stack for transformations there is also an "attribute stack" for attributes. That one is severly broken in Minecraft, however. There is a bug in `GlStateManager` that means that if you set an attribute using `GlStateManager` inside of a `pushAttrib`-`popAttrib` block, you sometimes cannot set that attribute again. This means when inside of such a block you cannot call Minecrafts wrappers in `GlStateManager` and you need to instead call OpenGL directly. I recommend against using this however, since vanilla code you call might still use `GlStateManager`, therefore breaking attributes until the next frame. Use these two methods very carefully if at all. + +#### Color + +`GlStateManager.color` is probably the easiest example of an attribute. You can use it to tint all your future render calls, including textures and text. You probably already set a color using `fr.drawString`, since that has a color argument, which in turn just calls `GlStateManager.color` after several layers of abstractions. If you want everything back to normal, just set the color to `color(1, 1, 1, 1)`. + +#### Depth + +`enableDepth` and `disableDepth` turn on and off depth testing. Meaning that the z value ignored and things with a high z value might render behind things with a low z value. Only the render order matters now, instead of the z value. + +On top of that you can use `depthFunc` along with `GL11.GL_LESS`, `GL11.GL_LEQUAL`, `GL11.GL_GREATER`, `GL_ALWAYS` (which is equivalent to `disableDepth`) etc, to decide in which direction the depth test functions. + +#### Blending + +Blending specifies how transparent images render on top of each other. Again you can `enableBlend` and `disableBlend`, as well as specify the function to use to blend two images. Again, you can use values from `GL11` like `GL11.GL_ONE_MINUS_SRC_ALPHA`, `GL11.GL_ONE_MINUS_DST_ALPHA`, `GL11.GL_SRC_ALPHA` and so on with `blendFunc` or `tryBlendFuncSeparate`. + +#### And many more + +There are a ton of attributes, some of them more useful, some of them less. Most of them map to regular OpenGL attributes, so you can always look up OpenGL tutorials on how to use them. Or check out other mods or vanilla code to see them in action. Just remember to always reset attributes to how you got them. + + +## Going beyond + +Minecraft has a few built in methods for drawing that I showed you in here. But Minecraft has many more, for rendering Items in UIs, and other things, but these basics should get you started. Be sure to always check out vanilla code for example usages. And if vanilla is missing something you want, you can always call the OpenGL primitives that Minecraft itself calls. + diff --git a/mkdocs.yml b/mkdocs.yml index 1957ac0..036c187 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,30 @@ site_name: Legacy Modding Wiki by nea89 +site_url: https://moddev.nea.moe/ +site_description: | + My wiki for 1.8.9 Forge modding beginners and experts alike. +repo_name: nea89o/ModDevWiki +repo_url: https://github.com/nea89o/ModDevWiki +edit_uri: blob/master/docs + + + +nav: + - index.md + - ide-setup.md + - events.md + - commands.md + - screens.md + - Mixins: + - mixins/index.md + - mixins/accessors.md + - mixins/adding-fields.md + - mixins/simple-injects.md + + +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn theme: name: material @@ -6,6 +32,36 @@ theme: - content.code.annotate - content.code.copy - content.tooltips + - navigation.top + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.tracking + - navigation.indexes + - content.action.edit + icon: + repo: fontawesome/brands/git-alt + palette: + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-sunny + name: Switch to dark mode + + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/weather-night + name: Switch to light mode + + +plugins: + - git-revision-date-localized: + enable_creation_date: true + fallback_to_build_date: true + markdown_extensions: - abbr @@ -55,4 +111,4 @@ markdown_extensions: case: lower - pymdownx.tasklist: custom_checkbox: true - - pymdownx.tilde \ No newline at end of file + - pymdownx.tilde diff --git a/requirements.txt b/requirements.txt index 1ee83b9..b712b13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ mkdocs-material - +mkdocs-git-revision-date-localized-plugin +mkdocs-git-committers-plugin-2 diff --git a/serve.sh b/serve.sh new file mode 100755 index 0000000..4b10a8b --- /dev/null +++ b/serve.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +( cd "$(dirname "$0")" +if ! [[ -d venv ]]; then + python3 -m venv venv +fi +source venv/bin/activate +pip install -r requirements.txt +mkdocs serve +) + + + -- cgit