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 <Berserker66@users.noreply.github.com>
This commit is contained in:
MarioManTAW
2025-07-23 16:27:50 -05:00
committed by GitHub
parent 0e4314ad1e
commit 8541c87c97
9 changed files with 394 additions and 0 deletions

View File

@@ -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

View File

@@ -136,6 +136,9 @@
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
# Paint
/worlds/paint/ @MarioManTAW
# Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe

128
worlds/paint/__init__.py Normal file
View File

@@ -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")}

View File

@@ -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.

View File

@@ -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.

48
worlds/paint/items.py Normal file
View File

@@ -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"]

24
worlds/paint/locations.py Normal file
View File

@@ -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()}

107
worlds/paint/options.py Normal file
View File

@@ -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

40
worlds/paint/rules.py Normal file
View File

@@ -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))