From 8541c87c9785cce98820002bec4ca4b8ad1c00b6 Mon Sep 17 00:00:00 2001 From: MarioManTAW Date: Wed, 23 Jul 2025 16:27:50 -0500 Subject: [PATCH] Paint: Implement New Game (#4955) * Paint: Implement New Game * Add docstring * Remove unnecessary self.multiworld references * Implement start_inventory_from_pool * Convert logic to use LogicMixin * Add location_exists_with_options function to deduplicate code * Simplify starting tool creation * Add Paint to supported games list * Increment version to 0.4.1 * Update docs to include color selection features * Fix world attribute definitions * Fix linting errors * De-duplicate lists of traps * Move LogicMixin to __init__.py * 0.5.0 features - adjustable canvas size increment, updated similarity metric * Fix OptionError formatting * Create OptionError when generating single-player game with error-prone settings * Increment version to 0.5.1 * Update CODEOWNERS * Update documentation for 0.5.2 client changes * Simplify region creation * Add comments describing logic * Remove unnecessary f-strings * Remove unused import * Refactor rules to location class * Remove unnecessary self.multiworld references * Update logic to correctly match client-side item caps --------- Co-authored-by: Fabian Dill --- README.md | 1 + docs/CODEOWNERS | 3 + worlds/paint/__init__.py | 128 ++++++++++++++++++++++++++++++++++ worlds/paint/docs/en_Paint.md | 35 ++++++++++ worlds/paint/docs/guide_en.md | 8 +++ worlds/paint/items.py | 48 +++++++++++++ worlds/paint/locations.py | 24 +++++++ worlds/paint/options.py | 107 ++++++++++++++++++++++++++++ worlds/paint/rules.py | 40 +++++++++++ 9 files changed, 394 insertions(+) create mode 100644 worlds/paint/__init__.py create mode 100644 worlds/paint/docs/en_Paint.md create mode 100644 worlds/paint/docs/guide_en.md create mode 100644 worlds/paint/items.py create mode 100644 worlds/paint/locations.py create mode 100644 worlds/paint/options.py create mode 100644 worlds/paint/rules.py diff --git a/README.md b/README.md index 29b6206a..44c44d72 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Currently, the following games are supported: * Jak and Daxter: The Precursor Legacy * Super Mario Land 2: 6 Golden Coins * shapez +* Paint For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 3104200a..85b31683 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -136,6 +136,9 @@ # Overcooked! 2 /worlds/overcooked2/ @toasterparty +# Paint +/worlds/paint/ @MarioManTAW + # Pokemon Emerald /worlds/pokemon_emerald/ @Zunawe diff --git a/worlds/paint/__init__.py b/worlds/paint/__init__.py new file mode 100644 index 00000000..8e501ff3 --- /dev/null +++ b/worlds/paint/__init__.py @@ -0,0 +1,128 @@ +from typing import Dict, Any + +from BaseClasses import CollectionState, Item, MultiWorld, Tutorial, Region +from Options import OptionError +from worlds.AutoWorld import LogicMixin, World, WebWorld +from .items import item_table, PaintItem, item_data_table, traps, deathlink_traps +from .locations import location_table, PaintLocation, location_data_table +from .options import PaintOptions + + +class PaintWebWorld(WebWorld): + theme = "partyTime" + + setup_en = Tutorial( + tutorial_name="Start Guide", + description="A guide to playing Paint in Archipelago.", + language="English", + file_name="guide_en.md", + link="guide/en", + authors=["MarioManTAW"] + ) + + tutorials = [setup_en] + + +class PaintWorld(World): + """ + The classic Microsoft app, reimagined as an Archipelago game! Find your tools, expand your canvas, and paint the + greatest image the world has ever seen. + """ + game = "Paint" + options_dataclass = PaintOptions + options: PaintOptions + web = PaintWebWorld() + location_name_to_id = location_table + item_name_to_id = item_table + origin_region_name = "Canvas" + + def generate_early(self) -> None: + if self.options.canvas_size_increment < 50 and self.options.logic_percent <= 55: + if self.multiworld.players == 1: + raise OptionError("Logic Percent must be greater than 55 when generating a single-player world with " + "Canvas Size Increment below 50.") + + def get_filler_item_name(self) -> str: + if self.random.randint(0, 99) >= self.options.trap_count: + return "Additional Palette Color" + elif self.options.death_link: + return self.random.choice(deathlink_traps) + else: + return self.random.choice(traps) + + def create_item(self, name: str) -> PaintItem: + item = PaintItem(name, item_data_table[name].type, item_data_table[name].code, self.player) + return item + + def create_items(self) -> None: + starting_tools = ["Brush", "Pencil", "Eraser/Color Eraser", "Airbrush", "Line", "Rectangle", "Ellipse", + "Rounded Rectangle"] + self.push_precollected(self.create_item("Magnifier")) + self.push_precollected(self.create_item(starting_tools.pop(self.options.starting_tool))) + items_to_create = ["Free-Form Select", "Select", "Fill With Color", "Pick Color", "Text", "Curve", "Polygon"] + items_to_create += starting_tools + items_to_create += ["Progressive Canvas Width"] * (400 // self.options.canvas_size_increment) + items_to_create += ["Progressive Canvas Height"] * (300 // self.options.canvas_size_increment) + depth_items = ["Progressive Color Depth (Red)", "Progressive Color Depth (Green)", + "Progressive Color Depth (Blue)"] + for item in depth_items: + self.push_precollected(self.create_item(item)) + items_to_create += depth_items * 6 + pre_filled = len(items_to_create) + to_fill = len(self.get_region("Canvas").locations) + if pre_filled > to_fill: + raise OptionError(f"{self.player_name}'s Paint world has too few locations for its required items. " + "Consider adding more locations by raising logic percent or adding fractional checks. " + "Alternatively, increasing the canvas size increment will require fewer items.") + while len(items_to_create) < (to_fill - pre_filled) * (self.options.trap_count / 100) + pre_filled: + if self.options.death_link: + items_to_create += [self.random.choice(deathlink_traps)] + else: + items_to_create += [self.random.choice(traps)] + while len(items_to_create) < to_fill: + items_to_create += ["Additional Palette Color"] + self.multiworld.itempool += [self.create_item(item) for item in items_to_create] + + def create_regions(self) -> None: + canvas = Region("Canvas", self.player, self.multiworld) + canvas.locations += [PaintLocation(self.player, loc_name, loc_data.address, canvas) + for loc_name, loc_data in location_data_table.items() + if location_exists_with_options(self, loc_data.address)] + + self.multiworld.regions += [canvas] + + def set_rules(self) -> None: + from .rules import set_completion_rules + set_completion_rules(self, self.player) + + def fill_slot_data(self) -> Dict[str, Any]: + return dict(self.options.as_dict("logic_percent", "goal_percent", "goal_image", "death_link", + "canvas_size_increment"), version="0.5.2") + + def collect(self, state: CollectionState, item: Item) -> bool: + change = super().collect(state, item) + if change: + state.paint_percent_stale[self.player] = True + return change + + def remove(self, state: CollectionState, item: Item) -> bool: + change = super().remove(state, item) + if change: + state.paint_percent_stale[self.player] = True + return change + + +def location_exists_with_options(world: PaintWorld, location: int): + l = location % 198600 + return l <= world.options.logic_percent * 4 and (l % 4 == 0 or + (l > world.options.half_percent_checks * 4 and l % 2 == 0) or + l > world.options.quarter_percent_checks * 4) + + +class PaintState(LogicMixin): + paint_percent_available: dict[int, float] # per player + paint_percent_stale: dict[int, bool] + + def init_mixin(self, multiworld: MultiWorld) -> None: + self.paint_percent_available = {player: 0 for player in multiworld.get_game_players("Paint")} + self.paint_percent_stale = {player: True for player in multiworld.get_game_players("Paint")} diff --git a/worlds/paint/docs/en_Paint.md b/worlds/paint/docs/en_Paint.md new file mode 100644 index 00000000..845c7268 --- /dev/null +++ b/worlds/paint/docs/en_Paint.md @@ -0,0 +1,35 @@ +# Paint + +## Where is the options page? + +You can read through all the options and generate a YAML [here](../player-options). + +## What does randomization do to this game? + +Most tools are locked from the start, leaving only the Magnifier and one drawing tool, specified in the game options. +Canvas size is locked and will only expand when the Progressive Canvas Width and Progressive Canvas Height items are +obtained. Additionally, color selection is limited, starting with only a few possible colors but gaining more options +when Progressive Color Depth items are obtained in each of the red, green, and blue components. + +Location checks are sent out based on similarity to a target image, measured as a percentage. Every percentage point up +to a maximum set in the game options will send a new check, and the game will be considered done when a certain target +percentage (also set in the game options) is reached. + +## What other changes are made to the game? + +This project is based on [JS Paint](https://jspaint.app), an open-source remake of Microsoft Paint. Most features will +work similarly to this version but some features have also been removed. Most notably, pasting functionality has been +completely removed to prevent cheating. + +With the addition of a second canvas to display the target image, there are some additional features that may not be +intuitive. There are two special functions in the Extras menu to help visualize how to improve your score. Similarity +Mode (shortcut Ctrl+Shift+M) shows the similarity of each portion of the image in grayscale, with white representing +perfect similarity and black representing no similarity. Conversely, Difference Mode (shortcut Ctrl+M) visualizes the +differences between what has been drawn and the target image in full color, showing the direction both hue and +lightness need to shift to match the target. Additionally, once unlocked, the Pick Color tool can be used on both the +main and target canvases. + +Custom colors have been streamlined for Archipelago play. The only starting palette options are black and white, but +additional palette slots can be unlocked as Archipelago items. Double-clicking on any palette slot will allow you to +edit the color in that slot directly and shift-clicking a palette slot will allow you to override the slot with your +currently selected color. diff --git a/worlds/paint/docs/guide_en.md b/worlds/paint/docs/guide_en.md new file mode 100644 index 00000000..8571ad3d --- /dev/null +++ b/worlds/paint/docs/guide_en.md @@ -0,0 +1,8 @@ +# Paint Randomizer Start Guide + +After rolling your seed, go to the [Archipelago Paint](https://mariomantaw.github.io/jspaint/) site and enter the +server details, your slot name, and a room password if one is required. Then click "Connect". If desired, you may then +load a custom target image with File->Open Goal Image. If playing asynchronously, note that progress is saved using the +hash that will appear at the end of the URL so it is recommended to leave the tab open or save the URL with the hash to +avoid losing progress. + diff --git a/worlds/paint/items.py b/worlds/paint/items.py new file mode 100644 index 00000000..c2ea2001 --- /dev/null +++ b/worlds/paint/items.py @@ -0,0 +1,48 @@ +from typing import NamedTuple, Dict + +from BaseClasses import Item, ItemClassification + + +class PaintItem(Item): + game = "Paint" + + +class PaintItemData(NamedTuple): + code: int + type: ItemClassification + + +item_data_table: Dict[str, PaintItemData] = { + "Progressive Canvas Width": PaintItemData(198501, ItemClassification.progression), + "Progressive Canvas Height": PaintItemData(198502, ItemClassification.progression), + "Progressive Color Depth (Red)": PaintItemData(198503, ItemClassification.progression), + "Progressive Color Depth (Green)": PaintItemData(198504, ItemClassification.progression), + "Progressive Color Depth (Blue)": PaintItemData(198505, ItemClassification.progression), + "Free-Form Select": PaintItemData(198506, ItemClassification.useful), + "Select": PaintItemData(198507, ItemClassification.useful), + "Eraser/Color Eraser": PaintItemData(198508, ItemClassification.useful), + "Fill With Color": PaintItemData(198509, ItemClassification.useful), + "Pick Color": PaintItemData(198510, ItemClassification.progression), + "Magnifier": PaintItemData(198511, ItemClassification.useful), + "Pencil": PaintItemData(198512, ItemClassification.useful), + "Brush": PaintItemData(198513, ItemClassification.useful), + "Airbrush": PaintItemData(198514, ItemClassification.useful), + "Text": PaintItemData(198515, ItemClassification.useful), + "Line": PaintItemData(198516, ItemClassification.useful), + "Curve": PaintItemData(198517, ItemClassification.useful), + "Rectangle": PaintItemData(198518, ItemClassification.useful), + "Polygon": PaintItemData(198519, ItemClassification.useful), + "Ellipse": PaintItemData(198520, ItemClassification.useful), + "Rounded Rectangle": PaintItemData(198521, ItemClassification.useful), + # "Change Background Color": PaintItemData(198522, ItemClassification.useful), + "Additional Palette Color": PaintItemData(198523, ItemClassification.filler), + "Undo Trap": PaintItemData(198524, ItemClassification.trap), + "Clear Image Trap": PaintItemData(198525, ItemClassification.trap), + "Invert Colors Trap": PaintItemData(198526, ItemClassification.trap), + "Flip Horizontal Trap": PaintItemData(198527, ItemClassification.trap), + "Flip Vertical Trap": PaintItemData(198528, ItemClassification.trap), +} + +item_table = {name: data.code for name, data in item_data_table.items()} +traps = ["Undo Trap", "Clear Image Trap", "Invert Colors Trap", "Flip Horizontal Trap", "Flip Vertical Trap"] +deathlink_traps = ["Invert Colors Trap", "Flip Horizontal Trap", "Flip Vertical Trap"] diff --git a/worlds/paint/locations.py b/worlds/paint/locations.py new file mode 100644 index 00000000..ce227991 --- /dev/null +++ b/worlds/paint/locations.py @@ -0,0 +1,24 @@ +from typing import NamedTuple, Dict + +from BaseClasses import CollectionState, Location + + +class PaintLocation(Location): + game = "Paint" + def access_rule(self, state: CollectionState): + from .rules import paint_percent_available + return paint_percent_available(state, state.multiworld.worlds[self.player], self.player) >=\ + (self.address % 198600) / 4 + + +class PaintLocationData(NamedTuple): + region: str + address: int + + +location_data_table: Dict[str, PaintLocationData] = { + # f"Similarity: {i}%": PaintLocationData("Canvas", 198500 + i) for i in range(1, 96) + f"Similarity: {i/4}%": PaintLocationData("Canvas", 198600 + i) for i in range(1, 381) +} + +location_table = {name: data.address for name, data in location_data_table.items()} diff --git a/worlds/paint/options.py b/worlds/paint/options.py new file mode 100644 index 00000000..95dee7d8 --- /dev/null +++ b/worlds/paint/options.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + +from Options import Range, PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Visibility + + +class LogicPercent(Range): + """Sets the maximum percent similarity required for a check to be in logic. + Higher values are more difficult and items/locations will not be generated beyond this number.""" + display_name = "Logic Percent" + range_start = 50 + range_end = 95 + default = 80 + + +class GoalPercent(Range): + """Sets the percent similarity required to achieve your goal. + If this number is higher than the value for logic percent, + reaching goal will be in logic upon obtaining all progression items.""" + display_name = "Goal Percent" + range_start = 50 + range_end = 95 + default = 80 + + +class HalfPercentChecks(Range): + """Sets the lowest percent at which locations will be created for each 0.5% of similarity. + Below this number, there will be a check every 1%. + Above this number, there will be a check every 0.5%.""" + display_name = "Half Percent Checks" + range_start = 0 + range_end = 95 + default = 50 + + +class QuarterPercentChecks(Range): + """Sets the lowest percent at which locations will be created for each 0.25% of similarity. + This number will override Half Percent Checks if it is lower.""" + display_name = "Quarter Percent Checks" + range_start = 0 + range_end = 95 + default = 70 + + +class CanvasSizeIncrement(Choice): + """Sets the number of pixels the canvas will expand for each width/height item received. + Ensure an adequate number of locations are generated if setting this below 50.""" + display_name = "Canvas Size Increment" + # option_10 = 10 + # option_20 = 20 + option_25 = 25 + option_50 = 50 + option_100 = 100 + default = 100 + + +class GoalImage(Range): + """Sets the numbered image you will be required to match. + See https://github.com/MarioManTAW/jspaint/tree/master/images/archipelago + for a list of possible images or choose random. + This can also be overwritten client-side by using File->Open.""" + display_name = "Goal Image" + range_start = 1 + range_end = 1 + default = 1 + visibility = Visibility.none + + +class StartingTool(Choice): + """Sets which tool (other than Magnifier) you will be able to use from the start.""" + option_brush = 0 + option_pencil = 1 + option_eraser = 2 + option_airbrush = 3 + option_line = 4 + option_rectangle = 5 + option_ellipse = 6 + option_rounded_rectangle = 7 + default = 0 + + +class TrapCount(Range): + """Sets the percentage of filler items to be replaced by random traps.""" + display_name = "Trap Fill Percent" + range_start = 0 + range_end = 100 + default = 0 + + +class DeathLink(Toggle): + """If on, using the Undo or Clear Image functions will send a death to all other players with death link on. + Receiving a death will clear the image and reset the history. + This option also prevents Undo and Clear Image traps from being generated in the item pool.""" + display_name = "Death Link" + + +@dataclass +class PaintOptions(PerGameCommonOptions): + logic_percent: LogicPercent + goal_percent: GoalPercent + half_percent_checks: HalfPercentChecks + quarter_percent_checks: QuarterPercentChecks + canvas_size_increment: CanvasSizeIncrement + goal_image: GoalImage + starting_tool: StartingTool + trap_count: TrapCount + death_link: DeathLink + start_inventory_from_pool: StartInventoryPool diff --git a/worlds/paint/rules.py b/worlds/paint/rules.py new file mode 100644 index 00000000..1c7844c1 --- /dev/null +++ b/worlds/paint/rules.py @@ -0,0 +1,40 @@ +from math import sqrt + +from BaseClasses import CollectionState +from . import PaintWorld + + +def paint_percent_available(state: CollectionState, world: PaintWorld, player: int) -> bool: + if state.paint_percent_stale[player]: + state.paint_percent_available[player] = calculate_paint_percent_available(state, world, player) + state.paint_percent_stale[player] = False + return state.paint_percent_available[player] + + +def calculate_paint_percent_available(state: CollectionState, world: PaintWorld, player: int) -> float: + p = state.has("Pick Color", player) + r = min(state.count("Progressive Color Depth (Red)", player), 7) + g = min(state.count("Progressive Color Depth (Green)", player), 7) + b = min(state.count("Progressive Color Depth (Blue)", player), 7) + if not p: + r = min(r, 2) + g = min(g, 2) + b = min(b, 2) + w = state.count("Progressive Canvas Width", player) + h = state.count("Progressive Canvas Height", player) + # This code looks a little messy but it's a mathematical formula derived from the similarity calculations in the + # client. The first line calculates the maximum score achievable for a single pixel with the current items in the + # worst possible case. This per-pixel score is then multiplied by the number of pixels currently available (the + # starting canvas is 400x300) over the total number of pixels with everything unlocked (800x600) to get the + # total score achievable assuming the worst possible target image. Finally, this is multiplied by the logic percent + # option which restricts the logic so as to not require pixel perfection. + return ((1 - ((sqrt(((2 ** (7 - r) - 1) ** 2 + (2 ** (7 - g) - 1) ** 2 + (2 ** (7 - b) - 1) ** 2) * 12)) / 765)) * + min(400 + w * world.options.canvas_size_increment, 800) * + min(300 + h * world.options.canvas_size_increment, 600) * + world.options.logic_percent / 480000) + + +def set_completion_rules(world: PaintWorld, player: int) -> None: + world.multiworld.completion_condition[player] = \ + lambda state: (paint_percent_available(state, world, player) >= + min(world.options.logic_percent, world.options.goal_percent))