mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00
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:
128
worlds/paint/__init__.py
Normal file
128
worlds/paint/__init__.py
Normal 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")}
|
35
worlds/paint/docs/en_Paint.md
Normal file
35
worlds/paint/docs/en_Paint.md
Normal 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.
|
8
worlds/paint/docs/guide_en.md
Normal file
8
worlds/paint/docs/guide_en.md
Normal 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
48
worlds/paint/items.py
Normal 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
24
worlds/paint/locations.py
Normal 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
107
worlds/paint/options.py
Normal 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
40
worlds/paint/rules.py
Normal 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))
|
Reference in New Issue
Block a user