about summary refs log tree commit diff stats
path: root/apworld/player_logic.py
blob: f67d7f958593e6654cd90b67978ec9ef4e581cf4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
from .generated import data_pb2 as data_pb2
from typing import TYPE_CHECKING, NamedTuple

if TYPE_CHECKING:
    from . import Lingo2World


def calculate_letter_histogram(solution: str) -> dict[str, int]:
    histogram = dict()
    for l in solution:
        if l.isalpha():
            real_l = l.upper()
            histogram[real_l] = min(histogram.get(l, 0) + 1, 2)

    return histogram


class AccessRequirements:
    items: set[str]
    rooms: set[str]
    symbols: set[str]
    letters: dict[str, int]

    # This is an AND of ORs.
    or_logic: list[list["AccessRequirements"]]

    def __init__(self):
        self.items = set()
        self.rooms = set()
        self.symbols = set()
        self.letters = dict()
        self.or_logic = list()

    def add_solution(self, solution: str):
        histogram = calculate_letter_histogram(solution)

        for l, a in histogram.items():
            self.letters[l] = max(self.letters.get(l, 0), histogram.get(l))

    def merge(self, other: "AccessRequirements"):
        for item in other.items:
            self.items.add(item)

        for room in other.rooms:
            self.rooms.add(room)

        for symbol in other.symbols:
            self.symbols.add(symbol)

        for letter, level in other.letters.items():
            self.letters[letter] = max(self.letters.get(letter, 0), level)

        for disjunction in other.or_logic:
            self.or_logic.append(disjunction)


class PlayerLocation(NamedTuple):
    code: int | None
    reqs: AccessRequirements


class Lingo2PlayerLogic:
    world: "Lingo2World"

    locations_by_room: dict[int, list[PlayerLocation]]
    item_by_door: dict[int, str]

    panel_reqs: dict[int, AccessRequirements]
    proxy_reqs: dict[int, dict[str, AccessRequirements]]
    door_reqs: dict[int, AccessRequirements]

    real_items: list[str]

    def __init__(self, world: "Lingo2World"):
        self.world = world
        self.locations_by_room = {}
        self.item_by_door = {}
        self.panel_reqs = dict()
        self.proxy_reqs = dict()
        self.door_reqs = dict()
        self.real_items = list()

        # We iterate through the doors in two parts because it is essential that we determine which doors are shuffled
        # before we calculate any access requirements.
        for door in world.static_logic.objects.doors:
            if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors:
                door_item_name = self.world.static_logic.get_door_item_name(door.id)
                self.item_by_door[door.id] = door_item_name
                self.real_items.append(door_item_name)

        for door in world.static_logic.objects.doors:
            if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.LOCATION_ONLY, data_pb2.DoorType.GRAVESTONE]:
                self.locations_by_room.setdefault(door.room_id, []).append(PlayerLocation(door.ap_id,
                                                                                          self.get_door_reqs(door.id)))

        for letter in world.static_logic.objects.letters:
            self.locations_by_room.setdefault(letter.room_id, []).append(PlayerLocation(letter.ap_id,
                                                                                        AccessRequirements()))

        for mastery in world.static_logic.objects.masteries:
            self.locations_by_room.setdefault(mastery.room_id, []).append(PlayerLocation(mastery.ap_id,
                                                                                         AccessRequirements()))

    def get_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
        if answer is None:
            if panel_id not in self.panel_reqs:
                self.panel_reqs[panel_id] = self.calculate_panel_reqs(panel_id, answer)

            return self.panel_reqs.get(panel_id)
        else:
            if panel_id not in self.proxy_reqs or answer not in self.proxy_reqs.get(panel_id):
                self.proxy_reqs.setdefault(panel_id, {})[answer] = self.calculate_panel_reqs(panel_id, answer)

            return self.proxy_reqs.get(panel_id).get(answer)

    def calculate_panel_reqs(self, panel_id: int, answer: str | None) -> AccessRequirements:
        panel = self.world.static_logic.objects.panels[panel_id]
        reqs = AccessRequirements()

        reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.room_id))

        if answer is not None:
            reqs.add_solution(answer)
        elif len(panel.proxies) > 0:
            for proxy in panel.proxies:
                proxy_reqs = AccessRequirements()
                proxy_reqs.add_solution(proxy.answer)

                reqs.or_logic.append([proxy_reqs])
        else:
            reqs.add_solution(panel.answer)

        for symbol in panel.symbols:
            reqs.symbols.add(symbol)

        if panel.HasField("required_door"):
            door_reqs = self.get_door_open_reqs(panel.required_door)
            reqs.merge(door_reqs)

        if panel.HasField("required_room"):
            reqs.rooms.add(self.world.static_logic.get_room_region_name(panel.required_room))

        return reqs

    # This gets/calculates the requirements described by the door object. This is most notably used as the requirements
    # for clearing a location, or opening a door when the door is not shuffled.
    def get_door_reqs(self, door_id: int) -> AccessRequirements:
        if door_id not in self.door_reqs:
            self.door_reqs[door_id] = self.calculate_door_reqs(door_id)

        return self.door_reqs.get(door_id)

    def calculate_door_reqs(self, door_id: int) -> AccessRequirements:
        door = self.world.static_logic.objects.doors[door_id]
        reqs = AccessRequirements()

        use_item = False
        if door.type in [data_pb2.DoorType.STANDARD, data_pb2.DoorType.ITEM_ONLY] and self.world.options.shuffle_doors:
            use_item = True

        if use_item:
            reqs.items.add(self.world.static_logic.get_door_item_name(door.id))
        else:
            # TODO: complete_at, control_center_color, switches, keyholders
            for proxy in door.panels:
                panel_reqs = self.get_panel_reqs(proxy.panel, proxy.answer if proxy.HasField("answer") else None)
                reqs.merge(panel_reqs)

            for room in door.rooms:
                reqs.rooms.add(self.world.static_logic.get_room_region_name(room))

            for sub_door_id in door.doors:
                sub_reqs = self.get_door_open_reqs(sub_door_id)
                reqs.merge(sub_reqs)

        return reqs

    # This gets the requirements to open a door within the world. When a door is shuffled, this means having the item
    # that acts as the door's key.
    def get_door_open_reqs(self, door_id: int) -> AccessRequirements:
        if door_id in self.item_by_door:
            reqs = AccessRequirements()
            reqs.items.add(self.item_by_door.get(door_id))

            return reqs
        else:
            return self.get_door_reqs(door_id)
span> end function LoopSound(filename) return mixer():loopSound("../res/sfx/" .. filename) end function StopSound(soundId) mixer():stopChannel(soundId) end function FadeToBlack(length) effect():fadeScreen(length, 1.0) repeat coroutine.yield() until effect():isScreenFadeComplete() end function RemoveFadeout(length) effect():fadeScreen(length, 0.0) repeat coroutine.yield() until effect():isScreenFadeComplete() end function FadeMap(length, amount) effect():fadeMap(length, amount) end function WaitForMapFade() while not effect():isMapFadeComplete() do coroutine.yield() end end function ShakeCamera(period) effect():shakeCamera(period) end function StopShakingCamera() effect():stopShakingCamera() end function PanToSprite(spriteName, length) local spriteId = getSpriteByAlias(spriteName) camera():panToSprite(spriteId, length) end function WaitForPan() while camera():isPanning() do coroutine.yield() end end function CameraFollowSprite(spriteName) local spriteId = getSpriteByAlias(spriteName) camera():setFollowingSprite(spriteId) camera():unlockCamera() end function ReturnCamera(length) local playerId = getPlayerSprite() camera():panToSprite(playerId, length) while camera():isPanning() do coroutine.yield() end camera():setFollowingSprite(playerId) camera():unlockCamera() end function SetPartyDirection(spriteId, direction) animation():setSpriteDirection(spriteId, direction) local sprite = getSprite(spriteId) for i=1,#sprite.followers do animation():setSpriteDirection(sprite.followers[i], direction) end end function SetPartyAnimation(spriteId, animName) animation():setSpriteAnimation(spriteId, animName) local sprite = getSprite(spriteId) for i=1,#sprite.followers do animation():setSpriteAnimation(sprite.followers[i], animName) end end function ChangeMap(map, warp, options) options = options or 0 local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) local direction = playerSprite.dir DisablePlayerControl() if (options & ChangeMapOptions.DO_NOT_FADE == 0) then FadeToBlack(150) end loadMap(map) character():transplantParty(playerId, getMap():getWarpPoint(warp), direction) if (options & ChangeMapOptions.DO_NOT_CHANGE_MUSIC == 0) then if (mixer():isPlayingMusic() and not getMap():hasMusic()) then mixer():fadeoutMusic(150) elseif (getMap():hasMusic() and (not mixer():isPlayingMusic() or not (mixer():getPlayingTrack() == getMap():getMusic()))) then mixer():playMusic(getMap():getMusic(), 150) end end coroutine.yield() if (options & ChangeMapOptions.DO_NOT_FADE == 0) then RemoveFadeout(150) end EnablePlayerControl() end function CreateAnimatedSpriteAtPosition(alias, character, x, y, animName, direction, layer) local spriteId = emplaceSprite(alias) transform():initSprite(spriteId, x, y, layer) animation():initSprite(spriteId, "../res/sprites/" .. character .. "_anim.txt") animation():setSpriteDirection(spriteId, direction) animation():setSpriteAnimation(spriteId, animName) end function CreateAnimatedSpriteAtWarpPoint(alias, character, warp, animName, direction, layer) local spriteId = emplaceSprite(alias) local loc = getMap():getWarpPoint(warp) transform():initSprite(spriteId, loc:x(), loc:y(), layer) animation():initSprite(spriteId, "../res/sprites/" .. character .. "_anim.txt") animation():setSpriteDirection(spriteId, direction) animation():setSpriteAnimation(spriteId, animName) end function DestroyNamedSprite(alias) local spriteId = getSpriteByAlias(alias) destroySprite(spriteId) end function AliasForSpriteExpression(spriteName) return "expression (" .. spriteName .. ")" end function ShowExpression(spriteName, expression) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) local animFrame = sprite:getCurrentFrame() local x = sprite.loc:x() local y = sprite.loc:y() - animFrame.center:y() CreateAnimatedSpriteAtPosition(AliasForSpriteExpression(spriteName), "expression", x, y, expression, Direction.DOWN, SpriteLayer.ABOVE) end function RemoveExpression(spriteName) DestroyNamedSprite(AliasForSpriteExpression(spriteName)) end --- Turns on clipping for the player. -- This allows walking through solid objects. For debug only! function StartClipping() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.clipping = true end --- Turns off clipping for the player. -- For debug only! function StopClipping() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.clipping = false end --- Turns off crouching (and thus running) for the player. function PreventCrouching() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.cantCrouch = true end --- Undoes the effect of PreventCrouching(). function AllowCrouching() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.cantCrouch = false end --- Makes a sprite start bobbing up and down (for underwater). -- This only applies when the sprite is on a normal medium (so, not on ladders). function StartBobbing(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.bobsWhenNormal = true end --- Makes a sprite stop bobbing up and down. function StopBobbing(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.bobsWhenNormal = false end --- Sets the animation slowdown for a sprite. -- @param spriteName the alias of the sprite to modify -- @param amount the number of animation frames needed to advance the sprite's animation (1 means the effect is disabled) function SetAnimationSlowdown(spriteName, amount) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.animSlowdown = amount end --- Sets the enclosure zone for a sprite. -- The sprite will be prevented from exiting the area defined by that zone. function AddEnclosureZone(spriteName, zone) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.enclosureZone = zone end --- Removes the enclosure zone for the specified sprite. -- This allows the sprite to move outside of the confines of the zone. function RemoveEnclosureZone(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.enclosureZone = "" end --- Set a sprite on a path to the specified location. function DirectSpriteToLocation(spriteName, warpPoint, options) options = options or 0 local spriteId = getSpriteByAlias(spriteName) local dest = getMap():getWarpPoint(warpPoint) behaviour():directSpriteToLocation(spriteId, dest, options) end --- Blocks until the specified sprite has completed their path. function WaitForSpritePath(spriteName) local spriteId = getSpriteByAlias(spriteName) while (behaviour():isFollowingPath(spriteId)) do coroutine.yield() end end --- Sets a sprite to wander. function StartWandering(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.behaviourType = BehaviourType.WANDER end --- Turns off the sprite's behaviour. function DisableBehaviour(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.behaviourType = BehaviourType.NONE end --- Directs a sprite to start following a target sprite. function FollowSprite(spriteName, targetName) local spriteId = getSpriteByAlias(spriteName) local targetId = getSpriteByAlias(targetName) local sprite = getSprite(spriteId) sprite.followSpriteId = targetId sprite.behaviourType = BehaviourType.FOLLOW end --- Makes a sprite stop following whatever sprite it was following. function StopFollowingSprite(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.followSpriteId = -1 sprite.behaviourType = BehaviourType.NONE end --- Fades out the currently playing music. -- This does not block. If you want it to block, call Delay for the same amount -- of time. -- @param length the fadeout time in milliseconds function FadeoutMusic(length) mixer():fadeoutMusic(length) end --- Plays the specified track. -- @param song the name of the song to play -- @param length the time in milliseconds to fade in. if left blank, the track starts immediately function PlayMusic(song, length) length = length or 0 mixer():playMusic(song, length) end --- Makes the player sprite non-controllable. function DisablePlayerControl() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.controllable = false end --- Makes the player sprite controllable again. function EnablePlayerControl() local playerId = getPlayerSprite() local playerSprite = getSprite(playerId) playerSprite.controllable = true end --- Makes the specified sprite face toward the †arget sprite. -- This version of the function uses any of the eight directions. -- @param spriteName the name of the sprite to change the direction of -- @param targetName the name of the sprite to face toward function FaceTowardSprite(spriteName, targetName) local spriteId = getSpriteByAlias(spriteName) local targetId = getSpriteByAlias(targetName) local sprite = getSprite(spriteId) local target = getSprite(targetId) local diff = vec2i.new(target.loc:x() - sprite.loc:x(), target.loc:y() - sprite.loc:y()) local dir = directionFacingPoint(diff) SetDirection(spriteName, dir) end --- Makes the specified sprite's entire party face toward the †arget sprite. -- This version of the function uses any of the eight directions. -- @param spriteName the name of the sprite to change the direction of -- @param targetName the name of the sprite to face toward function FacePartyTowardSprite(spriteName, targetName) FaceTowardSprite(spriteName, targetName) local sprite = getSprite(getSpriteByAlias(spriteName)) for i=1,#sprite.followers do local follower = getSprite(sprite.followers[i]) FaceTowardSprite(follower.alias, targetName) end end --- Makes the specified sprite face toward the †arget sprite. -- This version of the function uses the closest cardinal direction. -- @param spriteName the name of the sprite to change the direction of -- @param targetName the name of the sprite to face toward function FaceTowardSpriteCardinally(spriteName, targetName) local spriteId = getSpriteByAlias(spriteName) local targetId = getSpriteByAlias(targetName) local sprite = getSprite(spriteId) local target = getSprite(targetId) local diff = vec2i.new(target.loc:x() - sprite.loc:x(), target.loc:y() - sprite.loc:y()) local dir = cardinalDirectionFacingPoint(diff) SetDirection(spriteName, dir) end --- Detaches the sprite's followers and erases their following trails. function BreakUpParty(spriteName) local spriteId = getSpriteByAlias(spriteName) character():breakUpParty(spriteId) end --- Makes the specified sprite solid. -- This means that other sprites will be blocked if they collide with this one. function MakeSpriteSolid(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.solid = true end --- Makes the specified sprite not solid. -- This means that other sprites will not be blocked if they collide with this -- one. function MakeSpriteNotSolid(spriteName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.solid = false end --- Sets the sprite's movement speed. -- As a reference: 1 is slow (good for NPCs), 2 is Lucas's default walking speed function SetMovementSpeed(spriteName, speed) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.movementSpeed = speed end --- Performs the beginning of the exit area transition. -- This does the circle effect and plays the sound effect. The circle effect -- then gets removed and replaced with a screen fade. function ExitAreaTransition() DisablePlayerControl() --character():halt(getPlayerSprite()) coroutine.yield() PlaySound("exit_area.wav") FadeoutMusic(679) effect():circleTransition(679, 1.0) while not effect():isCircleTransitionComplete() do coroutine.yield() end character():halt(getPlayerSprite()) FadeToBlack(1) effect():circleTransition(1, 0.0) Delay(1000) end --- Checks whether a sprite is in a zone. -- @param spriteName the name of the sprite to locate -- @param zoneName the name of the zone on the current map to use as a boundary function IsSpriteInZone(spriteName, zoneName) local pos = GetPosition(spriteName) local zone = getMap():getZone(zoneName) return (pos:x() >= zone.ul:x()) and (pos:x() <= zone.dr:x()) and (pos:y() >= zone.ul:y()) and (pos:y() <= zone.dr:y()) end --- Sets the name of the script on the current map that will be executed when the player interacts with this sprite. function SetInteractionScript(spriteName, scriptName) local spriteId = getSpriteByAlias(spriteName) local sprite = getSprite(spriteId) sprite.interactionScript = scriptName end