├── src └── py_fumen │ ├── __init__.py │ ├── constants.py │ ├── comments.py │ ├── js_escape.py │ ├── page.py │ ├── fumen_buffer.py │ ├── defines.py │ ├── field.py │ ├── action.py │ ├── quiz.py │ ├── encoder.py │ ├── decoder.py │ └── inner_field.py ├── LICENSE └── README.md /src/py_fumen/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .encoder import * 4 | from .decoder import * 5 | from .page import * 6 | from .field import * 7 | from .quiz import * 8 | from .action import * 9 | from .defines import * 10 | from .inner_field import * -------------------------------------------------------------------------------- /src/py_fumen/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class FieldConstants(): 4 | GARBAGE_LINE = 1 5 | WIDTH = 10 6 | HEIGHT = 23 7 | PLAY_BLOCKS = WIDTH * HEIGHT 8 | MAX_HEIGHT = HEIGHT + GARBAGE_LINE 9 | MAX_BLOCKS = MAX_HEIGHT * WIDTH 10 | 11 | PREFIX = "v" 12 | VERSION = "115" 13 | SUFFIX = "@" 14 | VERSION_INFO = PREFIX + VERSION + SUFFIX -------------------------------------------------------------------------------- /src/py_fumen/comments.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from math import floor 4 | 5 | COMMENT_TABLE = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~' 6 | MAX_COMMENT_CHAR_VALUE = len(COMMENT_TABLE) + 1 7 | 8 | class CommentParser(): 9 | def decode(v: int) -> str: 10 | string = '' 11 | value = v 12 | for count in range (4): 13 | index = value % MAX_COMMENT_CHAR_VALUE 14 | string += COMMENT_TABLE[index] 15 | value = floor(value / MAX_COMMENT_CHAR_VALUE) 16 | 17 | return string 18 | 19 | def encode(ch: str, count: int) -> int: 20 | return COMMENT_TABLE.index(ch) * (MAX_COMMENT_CHAR_VALUE ** count) -------------------------------------------------------------------------------- /src/py_fumen/js_escape.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | 5 | ORIGINAL_TABLE = "0123456789QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm@*_+-./" 6 | 7 | def escape(string: str): 8 | result = "" 9 | for char in string: 10 | if char in ORIGINAL_TABLE: 11 | result += char 12 | 13 | else: 14 | char_index = ord(char) 15 | 16 | if char_index < 16**2: 17 | result += "%" + str(hex(char_index))[2:].upper() 18 | 19 | else: 20 | result += "%u" + str(hex(char_index))[2:].upper() 21 | 22 | return result 23 | 24 | def unescape(string: str): 25 | result = re.sub(r'%u([a-fA-F0-9]{4})|%([a-fA-F0-9]{2})', parse, string) 26 | 27 | return result 28 | 29 | def parse(hex_string: re.Match): 30 | hex_4, hex_2 = hex_string.groups() 31 | string = hex_4 if hex_4 is not None else hex_2 32 | return chr(int(string, 16)) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hsohliyt105 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # py-fumen 2 | Python implementation of knewjade's fumen 3 | 4 | # Installation 5 | Enter `pip install py-fumen` in terminal / cmd if you have python3 already. 6 | 7 | # Uses 8 | The usage of this package is very similar to the original fumen package. 9 | 10 | ## Decode 11 | ``` 12 | from py_fumen import decode 13 | 14 | decode_pages = decode("v115@vhHJEJWPJyKJz/I1QJUNJvIJAgH") 15 | 16 | for page in decode_pages: 17 | print(page.get_field().string()) 18 | ``` 19 | 20 | ## Encode 21 | ``` 22 | from py_fumen import Field, Page, encode, create_inner_field 23 | 24 | pages = [] 25 | pages.append( 26 | Page(field=create_inner_field(Field.create( 27 | 'LLL_____SS' + 28 | 'LOO____SST' + 29 | 'JOO___ZZTT' + 30 | 'JJJ____ZZT', 31 | '__________', 32 | )), 33 | comment='Perfect Clear Opener')) 34 | 35 | print(encode(pages)) 36 | ``` 37 | 38 | # Difference between the knewjade's fumen 39 | Some of functions and variables are non-private because of the disparity between python and typescript (e.g. quiz variable in the Quiz class). 40 | 41 | Function and varibale names are changed with python naming convention. 42 | 43 | create_inner_field function and create_new_inner_field are moved to field.py because of cross importing issue. 44 | 45 | page.py is created for better OOP. 46 | 47 | js_escape.py is added to imitate javascript's escape/unescape. 48 | 49 | buffer.ts is renamed to fumen_buffer.py and Buffer object to FumenBuffer. 50 | 51 | getters and setters are changed into methods (e.g. Page.get_field()). 52 | -------------------------------------------------------------------------------- /src/py_fumen/page.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from typing import List, Optional, Tuple 5 | from math import floor 6 | from urllib.parse import unquote 7 | 8 | from .inner_field import InnerField 9 | from .field import Field, Mino, Operation, create_inner_field 10 | 11 | @dataclass 12 | class Flags(): 13 | lock: Optional[bool] = True 14 | mirror: Optional[bool] = False 15 | colorize: Optional[bool] = True 16 | rise: Optional[bool] = False 17 | quiz: Optional[bool] = False 18 | 19 | @dataclass 20 | class Refs(): 21 | field: Optional[int] = None 22 | comment: Optional[int] = None 23 | 24 | class Page(): 25 | index: Optional[int] 26 | __field: Optional[InnerField] 27 | operation: Optional[Operation] 28 | comment: Optional[str] 29 | flag: Optional[Flags] 30 | refs: Optional[Refs] 31 | 32 | def __init__(self, index: Optional[int] = None, field: Optional[InnerField] = None, operation: Optional[Operation] = None, comment: Optional[str] = None, flags: Optional[Flags] = None, refs: Optional[Refs] = None): 33 | self.index = index 34 | self.__field = field.copy() 35 | self.operation = operation 36 | self.comment = comment 37 | self.flags = flags 38 | self.refs = refs 39 | 40 | return 41 | 42 | def get_field(self) -> Field: 43 | return Field(self.__field.copy()) 44 | 45 | def set_field(self, field: Field): 46 | self.__field = create_inner_field(field) 47 | 48 | def mino(self) -> Mino: 49 | return Mino.minoFrom(self.operation) -------------------------------------------------------------------------------- /src/py_fumen/fumen_buffer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import annotations 4 | from typing import List 5 | from math import floor 6 | 7 | ENCODE_TABLE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" 8 | 9 | def decode_to_value(v: str) -> int: 10 | return ENCODE_TABLE.index(v) 11 | 12 | def encode_from_value(index: int) -> str: 13 | return ENCODE_TABLE[index] 14 | 15 | class FumenBuffer(object): 16 | table_length: int = len(ENCODE_TABLE) 17 | 18 | values: List[int] 19 | 20 | class FumenException(Exception): 21 | pass 22 | 23 | def __init__(self, data: str = "") -> None: 24 | try: 25 | self.values = [ENCODE_TABLE.index(char) for char in [*data]] 26 | 27 | except: 28 | raise self.FumenException('Unexpected fumen') 29 | 30 | return 31 | 32 | def poll(self, maximum: int) -> int: 33 | value = 0 34 | 35 | for count in range(maximum): 36 | v = self.values.pop(0) 37 | 38 | if v is None: 39 | raise self.FumenException('Unexpected fumen') 40 | 41 | value += v * self.table_length ** count 42 | 43 | return value 44 | 45 | def push(self, value: int, split_count: int = 1): 46 | current = value 47 | for count in range(split_count): 48 | self.values.append(current % FumenBuffer.table_length) 49 | current = floor(current / FumenBuffer.table_length) 50 | 51 | return 52 | 53 | def merge(self, post_buffer: FumenBuffer): 54 | for value in post_buffer.values: 55 | self.values.append(value) 56 | 57 | return 58 | 59 | def is_empty(self) -> bool: 60 | return len(self.values) == 0 61 | 62 | def length(self) -> int: 63 | return len(self.values) 64 | 65 | def get(self, index: int) -> int: 66 | return self.values[index] 67 | 68 | def set(self, index: int, value: int) -> None: 69 | self.values[index] = value 70 | return 71 | 72 | def to_string(self) -> str: 73 | return ''.join(map(encode_from_value, self.values)) -------------------------------------------------------------------------------- /src/py_fumen/defines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from enum import IntEnum 5 | 6 | class Piece(IntEnum): 7 | EMPTY = 0 8 | I = 1 # Illegal naming convention but just followed knewjade 9 | L = 2 10 | O = 3 # Same here 11 | Z = 4 12 | T = 5 13 | J = 6 14 | S = 7 15 | GRAY = 8 16 | 17 | def is_mino_piece(piece: Piece): 18 | return piece is not Piece.EMPTY and piece is not Piece.GRAY 19 | 20 | class PieceException(Exception): 21 | pass 22 | 23 | def parse_piece_name(piece: Piece) -> str: 24 | if piece is Piece.I: 25 | return 'I' 26 | if piece is Piece.L: 27 | return 'L' 28 | if piece is Piece.O: 29 | return 'O' 30 | if piece is Piece.Z: 31 | return 'Z' 32 | if piece is Piece.T: 33 | return 'T' 34 | if piece is Piece.J: 35 | return 'J' 36 | if piece is Piece.S: 37 | return 'S' 38 | if piece is Piece.GRAY: 39 | return 'X' 40 | if piece is Piece.EMPTY: 41 | return '_' 42 | 43 | raise PieceException(f'Unknown piece: {repr(piece)}') 44 | 45 | def parse_piece(piece: str) -> Piece: 46 | piece = piece.upper() 47 | if piece in Piece.__members__.keys(): 48 | return Piece[piece] 49 | 50 | if piece == "X": 51 | return Piece.GRAY 52 | 53 | if piece == " " or piece == "_": 54 | return Piece.EMPTY 55 | 56 | raise PieceException(f'Unknown piece: {piece}') 57 | 58 | class Rotation(IntEnum): 59 | SPAWN = 2 60 | RIGHT = 1 61 | REVERSE = 0 62 | LEFT = 3 63 | 64 | class RotationException(Exception): 65 | pass 66 | 67 | def parse_rotation_name(rotation: Rotation) -> str: 68 | if rotation is Rotation.SPAWN: 69 | return 'spawn' 70 | if rotation is Rotation.LEFT: 71 | return 'left' 72 | if rotation is Rotation.RIGHT: 73 | return 'right' 74 | if rotation is Rotation.REVERSE: 75 | return 'reverse' 76 | 77 | raise RotationException(f'Unknown rotation: {repr(rotation)}') 78 | 79 | def parse_rotation(rotation: str) -> Rotation: 80 | rotation = rotation.lower() 81 | 82 | if rotation == 'spawn': 83 | return Rotation.SPAWN 84 | if rotation == 'left': 85 | return Rotation.LEFT 86 | if rotation == 'right': 87 | return Rotation.RIGHT 88 | if rotation == 'reverse': 89 | return Rotation.REVERSE 90 | 91 | raise RotationException(f'Unknown rotation: {rotation}') 92 | 93 | @dataclass 94 | class InnerOperation(): 95 | piece_type: Piece 96 | rotation: Rotation 97 | x: int 98 | y: int 99 | -------------------------------------------------------------------------------- /src/py_fumen/field.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import annotations 4 | from dataclasses import dataclass 5 | from typing import List, Optional, Tuple 6 | 7 | from .inner_field import get_block_xys, InnerField, PlayField 8 | from .defines import parse_piece, parse_piece_name, parse_rotation 9 | from .constants import FieldConstants 10 | 11 | @dataclass 12 | class Operation(): 13 | piece_type: str 14 | rotation: str 15 | x: int 16 | y: int 17 | 18 | @dataclass 19 | class XY(): 20 | x: int 21 | y: int 22 | 23 | @dataclass 24 | class Mino(): 25 | piece_type: str 26 | rotation: str 27 | x: int 28 | y: int 29 | 30 | @staticmethod 31 | def mino_from(operation: Operation) -> Mino: 32 | return Mino(operation.piece_type, operation.rotation, operation.x, operation.y) 33 | 34 | @staticmethod 35 | def get_sort_xy(xy: XY) -> Tuple[int, int]: 36 | return xy.y, xy.x 37 | 38 | def positions(self) -> List[XY]: 39 | return sorted(get_block_xys(parse_piece(self.piece_type), parse_rotation(self.rotation), self.x, self.y), key=self.get_sort_xy) 40 | 41 | def operation(self) -> Operation: 42 | return Operation(self.piece_type, self.rotation, self.x, self.y) 43 | 44 | def isValid(self) -> bool: 45 | try: 46 | parse_piece(self.piece_type) 47 | parse_rotation(self.rotation) 48 | 49 | except Exception as e: 50 | return False 51 | 52 | return all(0 <= position.x and position.x < 10 and 0 <= position.y and position.y < 23 for position in self.positions()) 53 | 54 | def copy(self) -> Mino: 55 | return Mino(self.piece_type, self.rotation, self.x, self.y) 56 | 57 | def to_mino(operation_or_mino: Operation | Mino) -> Mino: 58 | return operation_or_mino.copy() if operation_or_mino is Mino else Mino.mino_from(operation_or_mino) 59 | 60 | class Field(): 61 | __field: InnerField 62 | 63 | def __init__(self, field: InnerField): 64 | self.__field = field 65 | 66 | @staticmethod 67 | def create(field: Optional[str], garbage: Optional[str]) -> Field: 68 | return Field(InnerField(field=PlayField.load(field) if field is not None else None, garbage=PlayField.load_minify(garbage) if garbage is not None else None)) 69 | 70 | def can_fill(self, operation: Optional[Operation | Mino]) -> bool: 71 | if operation is None: 72 | return True 73 | 74 | mino = to_mino(operation) 75 | return self.__field.can_fill_all(mino.positions()) 76 | 77 | def can_lock(self, operation: Optional[Operation | Mino]) -> bool: 78 | if operation is None: 79 | return True 80 | 81 | if not self.can_fill(operation): 82 | return False 83 | 84 | # Check on the ground 85 | return not self.can_fill(Operation(operation.piece_type, operation.rotation, operation.x, operation.y-1)) 86 | 87 | class FillException(Exception): 88 | pass 89 | 90 | def fill(self, operation: Optional[Operation | Mino], force: bool = False) -> Optional[Mino]: 91 | if operation is None: 92 | return None 93 | 94 | mino = to_mino(operation) 95 | 96 | if not (force or self.can_fill(mino)): 97 | raise self.FillException('Cannot fill piece on field') 98 | 99 | self.__field.fill_all(mino.positions(), parse_piece(mino.type)) 100 | 101 | return mino 102 | 103 | class PutException(Exception): 104 | pass 105 | 106 | def put(self, operation: Optional[Operation | Mino]) -> Optional[Mino]: 107 | if operation is None: 108 | return None 109 | 110 | mino = to_mino(operation) 111 | 112 | while 0 <= mino.y: 113 | if not self.can_lock(mino): 114 | continue 115 | 116 | mino.y -= 1 117 | 118 | self.fill(mino) 119 | 120 | return mino 121 | 122 | raise self.PutException('Cannot put piece on field') 123 | 124 | def clear_line(self): 125 | self.__field.clear_line() 126 | 127 | def at(self, x: int, y: int) -> str: 128 | return parse_piece_name(self.__field.get_number_at(x, y)) 129 | 130 | def set(self, x: int, y: int, type: str): 131 | self.__field.set_number_at(x, y, parse_piece(type)) 132 | 133 | def copy(self) -> Field: 134 | return Field(self.__field.copy()) 135 | 136 | @dataclass 137 | class Option(): 138 | reduced: Optional[bool] = None 139 | separator: Optional[str] = None 140 | garbage: Optional[bool] = None 141 | 142 | def string(self, option: Option = Option()) -> str: 143 | skip = option.reduced if option.reduced is not None else True 144 | separator = option.separator if option.separator is not None else '\n' 145 | min_y = -1 if option.garbage is None or option.garbage else 0 146 | 147 | output = '' 148 | 149 | for y in range (22, min_y - 1, -1): 150 | line = '' 151 | for x in range(10): 152 | line += self.at(x, y) 153 | 154 | if skip and line == '__________': 155 | continue 156 | 157 | skip = False 158 | output += line 159 | if y != min_y: 160 | output += separator 161 | 162 | return output 163 | 164 | def create_new_inner_field() -> InnerField: 165 | return InnerField() 166 | 167 | def create_inner_field(field: Field) -> InnerField: 168 | inner_field = InnerField() 169 | for y in range(-1, FieldConstants.HEIGHT): 170 | for x in range(0, FieldConstants.WIDTH): 171 | at = field.at(x, y) 172 | inner_field.set_number_at(x, y, parse_piece(at)) 173 | 174 | return inner_field -------------------------------------------------------------------------------- /src/py_fumen/action.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from math import floor 5 | from typing import Tuple 6 | 7 | from .defines import is_mino_piece, InnerOperation, Piece, Rotation 8 | from .constants import FieldConstants 9 | 10 | @dataclass 11 | class Action(): 12 | piece: InnerOperation 13 | rise: bool 14 | mirror: bool 15 | colorize: bool 16 | comment: bool 17 | lock: bool 18 | 19 | def decode_bool(n: int): 20 | return n != 0 21 | 22 | class ActionDecoder() : 23 | width: int 24 | field_top: int 25 | garbage_line: int 26 | 27 | def __init__(self, width: int, field_top: int, garbage_line: int): 28 | self.width = width 29 | self.field_top = field_top 30 | self.garbage_line = garbage_line 31 | 32 | class PieceException(Exception): 33 | pass 34 | 35 | class RotationException(Exception): 36 | pass 37 | 38 | @staticmethod 39 | def decode_piece(n: int) -> Piece: 40 | if n in range(0, 9): 41 | return Piece(n) 42 | 43 | raise ActionDecoder.PieceException('Unexpected piece') 44 | 45 | @staticmethod 46 | def decode_rotation(n: int) -> Rotation: 47 | if n in range(0, 4): 48 | return Rotation(n) 49 | 50 | raise ActionDecoder.RotationException('Unexpected rotation') 51 | 52 | def decode_coordinate(self, n: int, piece: Piece, rotation: Rotation) -> Tuple[int, int]: 53 | x = n % self.width 54 | ORIGIN_Y = floor(n / 10) 55 | y = self.field_top - ORIGIN_Y - 1 56 | 57 | if piece is Piece.O and rotation is Rotation.LEFT: 58 | x += 1 59 | y -= 1 60 | elif piece is Piece.O and rotation is Rotation.REVERSE: 61 | x += 1 62 | elif piece is Piece.O and rotation is Rotation.SPAWN: 63 | y -= 1 64 | elif piece is Piece.I and rotation is Rotation.REVERSE: 65 | x += 1 66 | elif piece is Piece.I and rotation is Rotation.LEFT: 67 | y -= 1 68 | elif piece is Piece.S and rotation is Rotation.SPAWN: 69 | y -= 1 70 | elif piece is Piece.S and rotation is Rotation.RIGHT: 71 | x -= 1 72 | elif piece is Piece.Z and rotation is Rotation.SPAWN: 73 | y -= 1 74 | elif piece is Piece.Z and rotation is Rotation.LEFT: 75 | x += 1 76 | 77 | return (x, y) 78 | 79 | def decode(self, v: int) -> Action: 80 | value = v 81 | piece_type = self.decode_piece(value % 8) 82 | value = floor(value / 8) 83 | rotation = self.decode_rotation(value % 4) 84 | value = floor(value / 4) 85 | coordinate = self.decode_coordinate(value % FieldConstants.MAX_BLOCKS, piece_type, rotation) 86 | value = floor(value / FieldConstants.MAX_BLOCKS) 87 | is_block_up = decode_bool(value % 2) 88 | value = floor(value / 2) 89 | is_mirror = decode_bool(value % 2) 90 | value = floor(value / 2) 91 | is_color = decode_bool(value % 2) 92 | value = floor(value / 2) 93 | is_comment = decode_bool(value % 2) 94 | value = floor(value / 2) 95 | is_lock = not decode_bool(value % 2) 96 | 97 | return Action( 98 | piece = InnerOperation( 99 | x = coordinate[0], 100 | y = coordinate[1], 101 | piece_type = piece_type, 102 | rotation = rotation 103 | ), 104 | rise = is_block_up, 105 | mirror = is_mirror, 106 | colorize = is_color, 107 | comment = is_comment, 108 | lock = is_lock) 109 | 110 | def encode_bool(flag: bool) -> int: 111 | return 1 if flag else 0 112 | 113 | class ActionEncoder(): 114 | width: int 115 | field_top: int 116 | garbage_line: int 117 | 118 | def __init__(self, width: int, field_top: int, garbage_line: int): 119 | self.width = width 120 | self.field_top = field_top 121 | self.garbage_line = garbage_line 122 | 123 | def encode_position(self, operation: InnerOperation) -> int: 124 | piece_type = operation.piece_type 125 | rotation = operation.rotation 126 | x = operation.x 127 | y = operation.y 128 | 129 | if not is_mino_piece(piece_type): 130 | x = 0 131 | y = 22 132 | elif piece_type is Piece.O and rotation is Rotation.LEFT: 133 | x -= 1 134 | y += 1 135 | elif piece_type is Piece.O and rotation is Rotation.REVERSE: 136 | x -= 1 137 | elif piece_type is Piece.O and rotation is Rotation.SPAWN: 138 | y += 1 139 | elif piece_type is Piece.I and rotation is Rotation.REVERSE: 140 | x -= 1 141 | elif piece_type is Piece.I and rotation is Rotation.LEFT: 142 | y += 1 143 | elif piece_type is Piece.S and rotation is Rotation.SPAWN: 144 | y += 1 145 | elif piece_type is Piece.S and rotation is Rotation.RIGHT: 146 | x += 1 147 | elif piece_type is Piece.Z and rotation is Rotation.SPAWN: 148 | y += 1 149 | elif piece_type is Piece.Z and rotation is Rotation.LEFT: 150 | x -= 1 151 | 152 | return (self.field_top - y - 1) * self.width + x 153 | 154 | class NonReachableException(Exception): 155 | pass 156 | 157 | @staticmethod 158 | def encode_rotation(operation: InnerOperation) -> int: 159 | if not is_mino_piece(operation.piece_type): 160 | return 0 161 | 162 | if isinstance(operation.rotation, Rotation): 163 | return operation.rotation.value 164 | 165 | raise ActionEncoder.NonReachableException('No reachable') 166 | 167 | def encode(self, action: Action) -> int: 168 | value = encode_bool(not action.lock) 169 | value *= 2 170 | value += encode_bool(action.comment) 171 | value *= 2 172 | value += encode_bool(action.colorize) 173 | value *= 2 174 | value += encode_bool(action.mirror) 175 | value *= 2 176 | value += encode_bool(action.rise) 177 | value *= FieldConstants.MAX_BLOCKS 178 | value += self.encode_position(action.piece) 179 | value *= 4 180 | value += self.encode_rotation(action.piece) 181 | value *= 8 182 | value += action.piece.piece_type.value 183 | 184 | return value -------------------------------------------------------------------------------- /src/py_fumen/quiz.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import annotations 4 | from enum import Enum 5 | from typing import List, Optional 6 | from re import search 7 | 8 | from .defines import parse_piece, parse_piece_name, Piece 9 | 10 | class Operation(Enum): 11 | DIRECT = 'direct' 12 | SWAP = 'swap' 13 | STOCK = 'stock' 14 | 15 | class Quiz(): 16 | quiz: str 17 | 18 | @staticmethod 19 | def trim(quiz: str) -> str: 20 | return quiz.strip() 21 | 22 | class PieceException(Exception): 23 | pass 24 | 25 | @staticmethod 26 | def __verify(quiz: str) -> str: 27 | replaced = Quiz.trim(quiz) 28 | 29 | if len(replaced) == 0 or quiz == '#Q=[]()' or not quiz.startswith('#Q='): 30 | return quiz 31 | 32 | if not(search("^#Q=\[[TIOSZJL]?]\([TIOSZJL]?\)[TIOSZJL]*;?.*$", replaced)): 33 | raise Quiz.PieceException(f"Current piece doesn't exist, however next pieces exist: {quiz}") 34 | 35 | return replaced 36 | 37 | def __init__(self, quiz: str): 38 | self.quiz = self.__verify(quiz) 39 | 40 | def next(self) -> str: 41 | index = self.quiz.find(')') + 1 42 | name = self.quiz[index] 43 | 44 | if name is None or name == ';': 45 | return '' 46 | 47 | return name 48 | 49 | @staticmethod 50 | def is_quiz_comment(comment: str) -> bool: 51 | return comment.startswith('#Q=') 52 | 53 | @staticmethod 54 | def create(first: str, second: Optional[str]=None) -> Quiz: 55 | if second is None: 56 | return Quiz(f"#Q=[]({first[0]}){first[1:]}") 57 | return Quiz(f"#Q=[{first}]({second[0]}){second[1:]}") 58 | 59 | def least(self) -> str: 60 | index = self.quiz.find(')') 61 | return self.quiz[index+1:] 62 | 63 | def current(self) -> str: 64 | index = self.quiz.find('(') + 1 65 | name = self.quiz[index] 66 | if name == ')': 67 | return '' 68 | 69 | return name 70 | 71 | def hold(self) -> str: 72 | index = self.quiz.find('[') + 1 73 | name = self.quiz[index] 74 | if name == ']': 75 | return '' 76 | 77 | return name 78 | 79 | def least_after_next2(self) -> str: 80 | index = self.quiz.find(')') 81 | if self.quiz[index+1] == ';': 82 | return self.quiz[index+1:] 83 | 84 | return self.quiz[index+2:] 85 | 86 | class HoldException(Exception): 87 | pass 88 | 89 | def get_operation(self, used: Piece) -> Operation: 90 | used_name = parse_piece_name(used) 91 | current = self.current() 92 | if used_name == current: 93 | return Operation.DIRECT 94 | 95 | hold = self.hold() 96 | if used_name == hold: 97 | return Operation.SWAP 98 | 99 | if hold == '': 100 | if used_name == self.next(): 101 | return Operation.STOCK 102 | 103 | else: 104 | if current == '' and used_name == self.next(): 105 | return Operation.DIRECT 106 | 107 | raise self.HoldException(f"Unexpected hold piece in quiz: {self.quiz}") 108 | 109 | def least_in_active_bag(self) -> str: 110 | separate_index = self.quiz.find(';') 111 | quiz = self.quiz[0:separate_index] if 0 <= separate_index else self.quiz 112 | index = quiz.find(')') 113 | if quiz[index+1] == ';': 114 | return quiz[index+1:] 115 | 116 | return quiz[index+2:] 117 | 118 | def direct(self) -> Quiz: 119 | if self.current() == '': 120 | least = self.least_after_next2() 121 | return Quiz(f"#Q=[{self.hold()}]({least[0]}){least[1:]}") 122 | 123 | return Quiz(f"#Q=[{self.hold()}]({self.next()}){self.least_after_next2()}") 124 | 125 | def swap(self) -> Quiz: 126 | if self.hold() == '': 127 | raise self.HoldException(f"Cannot find hold piece: {self.quiz}") 128 | 129 | next = self.next() 130 | return Quiz(f"#Q=[{self.current()}]({next}){self.least_after_next2()}") 131 | 132 | class StockException(Exception): 133 | pass 134 | 135 | def stock(self) -> Quiz: 136 | if self.hold() != '' or self.next() == '': 137 | raise self.StockException(f"Cannot stock: {self.quiz}") 138 | 139 | least = self.least_after_next2() 140 | head = least[0] if least[0] is not None else '' 141 | 142 | if 1 < len(least): 143 | return Quiz(f"#Q=[{self.current()}]({head}){least[1:]}") 144 | 145 | return Quiz(f"#Q=[{self.current()}]({head})") 146 | 147 | class OperationException(Exception): 148 | pass 149 | 150 | def operate(self, operation: Operation) -> Quiz: 151 | if operation is Operation.DIRECT: 152 | return self.direct() 153 | if operation is Operation.SWAP: 154 | return self.swap() 155 | if operation is Operation.STOCK: 156 | return self.stock() 157 | 158 | raise self.OperationException('Unexpected operation') 159 | 160 | def can_operate(self) -> bool: 161 | quiz = self.quiz 162 | if quiz.startswith('#Q=[]();'): 163 | quiz = self.quiz[8:] 164 | 165 | return quiz.startswith('#Q=') and quiz != '#Q=[]()' 166 | 167 | def get_hold_piece(self) -> Piece: 168 | if not self.can_operate(): 169 | return Piece.EMPTY 170 | 171 | name = self.hold() 172 | if name is None or name == '' or name == ';': 173 | return Piece.EMPTY 174 | 175 | return parse_piece(name) 176 | 177 | def get_next_pieces(self, maximum: Optional[int] = None) -> List[Piece]: 178 | if not self.can_operate(): 179 | return [Piece.EMPTY] * maximum if maximum is not None else [] 180 | 181 | names = (self.current() + self.next() + self.least_in_active_bag())[0:maximum] 182 | if maximum is not None and len(names) < maximum: 183 | names += ' ' * (maximum - len(names)) 184 | 185 | return [Piece.EMPTY if name is None or name == ' ' or name == ';' else parse_piece_name(name) for name in [*names]] 186 | 187 | def to_string(self) -> str: 188 | return self.quiz 189 | 190 | def next_if_end(self) -> Quiz: 191 | if self.quiz.startswith('#Q=[]();'): 192 | return Quiz(self.quiz[8:]) 193 | 194 | return self 195 | 196 | def format(self) -> Quiz: 197 | quiz = self.next_if_end() 198 | if quiz.quiz == '#Q=[]()': 199 | return Quiz('') 200 | 201 | current = quiz.current() 202 | hold = quiz.hold() 203 | 204 | if current == '' and hold != '': 205 | return Quiz(f"#Q=[]({hold}){quiz.least()}") 206 | 207 | if current == '': 208 | least = quiz.least() 209 | head = least[0] 210 | if head is None: 211 | return Quiz('') 212 | 213 | if head == ';': 214 | return Quiz(least[1:]) 215 | 216 | return Quiz(f"#Q=[]({head}){least[1:]}") 217 | 218 | return quiz 219 | -------------------------------------------------------------------------------- /src/py_fumen/encoder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from typing import List, Optional, Tuple 4 | from urllib.parse import quote 5 | from re import findall 6 | 7 | from .page import Page, Flags 8 | from .inner_field import InnerField 9 | from .fumen_buffer import FumenBuffer 10 | from .field import create_inner_field, create_new_inner_field, Field, Operation 11 | from .defines import is_mino_piece, parse_piece, parse_rotation, Piece, Rotation, InnerOperation 12 | from .action import ActionEncoder, Action 13 | from .comments import CommentParser 14 | from .quiz import Quiz 15 | from .constants import FieldConstants, VERSION_INFO 16 | from .js_escape import escape 17 | 18 | # Calculate difference from previous field: 0 to 16 19 | def get_diff(prev: InnerField, current: InnerField, x_index: int, y_index: int) -> int: 20 | y: int = FieldConstants.HEIGHT - y_index - 1 21 | return current.get_number_at(x_index, y).value - prev.get_number_at(x_index, y).value + 8 22 | 23 | # data recording 24 | def record_block_counts(fumen_buffer: FumenBuffer, diff: int, counter: int): 25 | value: int = diff * FieldConstants.MAX_BLOCKS + counter 26 | fumen_buffer.push(value, 2) 27 | 28 | # encode the field 29 | # Specify an empty field if there is no previous field 30 | # The input field has a height of 23 and a width of 10 31 | def encode_field(prev: InnerField, current: InnerField) -> Tuple[bool, FumenBuffer]: 32 | fumen_buffer = FumenBuffer() 33 | 34 | # Convert from field value to number of consecutive blocks 35 | changed = True 36 | prev_diff = get_diff(prev, current, 0, 0) 37 | counter = -1 38 | for y_index in range(FieldConstants.MAX_HEIGHT): 39 | for x_index in range(FieldConstants.WIDTH): 40 | diff = get_diff(prev, current, x_index, y_index) 41 | if diff != prev_diff: 42 | record_block_counts(fumen_buffer, prev_diff, counter) 43 | counter = 0 44 | prev_diff = diff 45 | else: 46 | counter += 1 47 | 48 | # process last contiguous block 49 | record_block_counts(fumen_buffer, prev_diff, counter) 50 | if prev_diff == 8 and counter == FieldConstants.MAX_BLOCKS - 1: 51 | changed = False 52 | 53 | return (changed, fumen_buffer) 54 | 55 | def ensure_bool(obj: Optional[bool]) -> bool: 56 | return False if obj is None else obj 57 | 58 | def encode(pages: List[Page]) -> str: 59 | last_repeat_index = -1 60 | fumen_buffer = FumenBuffer() 61 | prev_field = create_new_inner_field() 62 | 63 | action_encoder = ActionEncoder(FieldConstants.WIDTH, FieldConstants.HEIGHT, FieldConstants.GARBAGE_LINE) 64 | 65 | prev_comment: Optional[str] = '' 66 | prev_quiz: Optional[Quiz] = None 67 | 68 | for index in range(0, len(pages)): 69 | current_page = pages[index] 70 | current_page.flags = current_page.flags if current_page.flags is not None else Flags() 71 | 72 | if isinstance(current_page, Page): 73 | field: Field = current_page.get_field() 74 | 75 | else: 76 | field: Field = current_page.field 77 | 78 | current_field: InnerField = create_inner_field(field) if field is not None else prev_field.copy() 79 | 80 | # Field update 81 | changed, values = encode_field(prev_field, current_field) 82 | 83 | if changed: 84 | # Record field and end repeat 85 | fumen_buffer.merge(values) 86 | last_repeat_index = -1 87 | 88 | elif last_repeat_index < 0 or fumen_buffer.get(last_repeat_index) == FumenBuffer.table_length - 1: 89 | # Record a field and start repeating 90 | fumen_buffer.merge(values) 91 | fumen_buffer.push(0) 92 | last_repeat_index = fumen_buffer.length() - 1 93 | 94 | elif fumen_buffer.get(last_repeat_index) < FumenBuffer.table_length - 1: 95 | # Do not record the field, advance the repeat 96 | current_repeat_value = fumen_buffer.get(last_repeat_index) 97 | fumen_buffer.set(last_repeat_index, current_repeat_value + 1) 98 | 99 | # Update action 100 | current_comment = (current_page.comment if index != 0 or current_page.comment != '' else None) if current_page.comment is not None else None 101 | 102 | piece = InnerOperation(parse_piece(current_page.operation.piece_type), 103 | parse_rotation(current_page.operation.rotation), 104 | current_page.operation.x, 105 | current_page.operation.y) if current_page.operation is not None else InnerOperation(Piece.EMPTY, Rotation.REVERSE, 0, 22,) 106 | 107 | next_comment: Optional[str] = None 108 | if current_comment is not None: 109 | if current_comment.startswith('#Q='): 110 | # Quiz on 111 | if prev_quiz is not None and prev_quiz.format().to_string() == current_comment: 112 | next_comment = None 113 | else: 114 | next_comment = current_comment 115 | prev_comment = next_comment 116 | prev_quiz = Quiz(current_comment) 117 | 118 | else: 119 | # Quiz off 120 | if prev_quiz is not None and prev_quiz.format().to_string() == current_comment: 121 | next_comment = None 122 | prev_comment = current_comment 123 | prev_quiz = None 124 | else: 125 | next_comment = current_comment if prev_comment != current_comment else None 126 | prev_comment = next_comment if prev_comment != current_comment else prev_comment 127 | prev_quiz = None 128 | 129 | else: 130 | next_comment = None 131 | prev_quiz = None 132 | 133 | if prev_quiz is not None and prev_quiz.can_operate() and current_page.flags.lock: 134 | if is_mino_piece(piece.piece_type): 135 | try: 136 | next_quiz = prev_quiz.next_if_end() 137 | operation = next_quiz.get_operation(piece.piece_type) 138 | prev_quiz = next_quiz.operate(operation) 139 | except Exception as e: 140 | # console.error(e.message) 141 | 142 | # Not operate 143 | prev_quiz = prev_quiz.format() 144 | 145 | else: 146 | prev_quiz = prev_quiz.format() 147 | 148 | current_flags = current_page.flags 149 | 150 | action = Action(piece, 151 | ensure_bool(current_flags.rise), 152 | ensure_bool(current_flags.mirror), 153 | ensure_bool(current_flags.colorize), 154 | next_comment is not None, 155 | ensure_bool(current_flags.lock),) 156 | 157 | action_number = action_encoder.encode(action) 158 | 159 | fumen_buffer.push(action_number, 3) 160 | 161 | # Comment update 162 | if next_comment is not None: 163 | comment: str = escape(current_page.comment) 164 | comment_length = min(len(comment), 4095) 165 | 166 | fumen_buffer.push(comment_length, 2) 167 | 168 | # Encode comments 169 | for index in range(0, comment_length, 4): 170 | value = 0 171 | for count in range (4): 172 | new_index = index + count 173 | if comment_length <= new_index: 174 | break 175 | 176 | ch = comment[new_index] 177 | value += CommentParser.encode(ch, count) 178 | 179 | fumen_buffer.push(value, 5) 180 | 181 | elif current_page.comment is None: 182 | prev_comment = None 183 | 184 | # terrain update 185 | if action.lock: 186 | if is_mino_piece(action.piece.piece_type): 187 | current_field.fill(action.piece) 188 | 189 | current_field.clear_line() 190 | 191 | if action.rise: 192 | current_field.rise_garbage() 193 | 194 | if action.mirror: 195 | current_field.mirror() 196 | 197 | prev_field = current_field 198 | 199 | # If the teto score is short, output it as is 200 | # A ? is inserted every 47 characters, but v115@ is actually placed at the beginning, so the first ? is 42 characters later. 201 | data = fumen_buffer.to_string() 202 | if len(data) < 41: 203 | return VERSION_INFO + data 204 | 205 | # ?to insert 206 | head = [data[0:42]] 207 | tails = data[42:] 208 | split = findall("[\S]{1,47}", tails) 209 | 210 | return VERSION_INFO + '?'.join(head + split) 211 | -------------------------------------------------------------------------------- /src/py_fumen/decoder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from dataclasses import dataclass 4 | from typing import List, Optional, Tuple 5 | from math import floor 6 | 7 | from .page import Page, Flags, Refs 8 | from .inner_field import InnerField 9 | from .fumen_buffer import FumenBuffer 10 | from .defines import is_mino_piece, parse_piece_name, parse_rotation_name, Piece 11 | from .action import ActionDecoder 12 | from .comments import CommentParser 13 | from .quiz import Quiz 14 | from .field import create_new_inner_field, Mino, Operation 15 | from .constants import FieldConstants 16 | from .js_escape import unescape 17 | 18 | class VersionException(Exception): 19 | pass 20 | 21 | def format_data(version: str, data: str) -> Tuple[str, str]: 22 | trim = data.strip().replace("?", "") 23 | return (version, trim) 24 | 25 | def extract(string: str) -> Tuple[str, str]: 26 | data = string 27 | 28 | # url parameters 29 | param_index = data.find('&') 30 | if 0 <= param_index: 31 | data = data[0:param_index] 32 | 33 | for version in [ "115", "110" ]: 34 | for prefix in [ "v", "m", "d" ]: 35 | match = string.find(prefix+version) 36 | if match != -1: 37 | sub = data[match + 5:] 38 | return format_data(version, sub) 39 | 40 | raise VersionException("Unsupported fumen version") 41 | 42 | def decode(fumen: str) -> List[Page]: 43 | version, data = extract(fumen) 44 | if version == "115": 45 | return inner_decode(data, 23) 46 | if version == "110": 47 | return inner_decode(data, 21) 48 | 49 | raise VersionException("Unsupported fumen version") 50 | 51 | @dataclass 52 | class RefIndex(): 53 | comment: int 54 | field: int 55 | 56 | @dataclass 57 | class Store(): 58 | repeat_count: int 59 | ref_index: RefIndex 60 | last_comment_text: str 61 | quiz: Optional[Quiz] = None 62 | 63 | @dataclass 64 | class FieldObj(): 65 | changed: bool 66 | field: InnerField 67 | 68 | @dataclass 69 | class Comment(): 70 | text: Optional[str] = None 71 | ref: Optional[int] = None 72 | 73 | @dataclass 74 | class PageField(): 75 | ref: Optional[int] = None 76 | 77 | def inner_decode(data: str, field_top: int) -> List[Page]: 78 | field_max_height = field_top + FieldConstants.GARBAGE_LINE 79 | num_field_blocks = field_max_height * FieldConstants.WIDTH 80 | 81 | fumen_buffer = FumenBuffer(data) 82 | 83 | page_index = 0 84 | prev_field = create_new_inner_field() 85 | 86 | store = Store(-1, RefIndex(0, 0), '', None) 87 | 88 | pages: List[Page] = [] 89 | action_decoder = ActionDecoder(FieldConstants.WIDTH, field_top, FieldConstants.GARBAGE_LINE) 90 | 91 | while not fumen_buffer.is_empty(): 92 | # Parse field 93 | current_field_obj = FieldObj(False, create_new_inner_field()) 94 | 95 | if 0 < store.repeat_count: 96 | current_field_obj = FieldObj(False, prev_field) 97 | 98 | store.repeat_count -= 1 99 | 100 | else: 101 | result = FieldObj(True, prev_field.copy()) 102 | index = 0 103 | while index < num_field_blocks: 104 | diff_block = fumen_buffer.poll(2) 105 | diff = floor(diff_block / num_field_blocks) 106 | 107 | num_of_blocks = diff_block % num_field_blocks 108 | 109 | if diff == 8 and num_of_blocks == num_field_blocks - 1: 110 | result.changed = False 111 | 112 | for block in range(0, num_of_blocks + 1): 113 | x = index % FieldConstants.WIDTH 114 | y = field_top - floor(index / FieldConstants.WIDTH) - 1 115 | result.field.add_number(x, y, diff - 8) 116 | index += 1 117 | 118 | current_field_obj = result 119 | 120 | if not current_field_obj.changed: 121 | store.repeat_count = fumen_buffer.poll(1) 122 | 123 | # Parse action 124 | action_value = fumen_buffer.poll(3) 125 | 126 | action = action_decoder.decode(action_value) 127 | 128 | # Parse comment 129 | comment: Comment 130 | if action.comment: 131 | 132 | # when there is an update in the comment 133 | comment_values: List[int] = [] 134 | comment_length = fumen_buffer.poll(2) 135 | 136 | for comment_counter in range(0, floor((comment_length + 3) / 4)): 137 | comment_value = fumen_buffer.poll(5) 138 | 139 | comment_values.append(comment_value) 140 | 141 | flatten: str = '' 142 | for value in comment_values: 143 | flatten += CommentParser.decode(value) 144 | 145 | #this is the problem. javascript escape vs python quote 146 | comment_text = unescape(flatten[0:comment_length]) 147 | store.last_comment_text = comment_text 148 | comment = Comment(text=comment_text) 149 | store.ref_index.comment = page_index 150 | 151 | text = comment.text 152 | if Quiz.is_quiz_comment(text): 153 | try: 154 | store.quiz = Quiz(text) 155 | except: 156 | store.quiz = None 157 | 158 | else: 159 | store.quiz = None 160 | 161 | elif page_index == 0: 162 | # When there is no update in the comment but on the first page 163 | comment = Comment(text='') 164 | else: 165 | # when there is no update in the comment 166 | comment = Comment(text=store.quiz.format().to_string() if store.quiz is not None else None, ref=store.ref_index.comment) 167 | 168 | # Acquire the operation for Quiz and advance the Quiz at the beginning of the next page by one step 169 | quiz = False 170 | if store.quiz is not None: 171 | quiz = True 172 | 173 | if store.quiz.can_operate() and action.lock: 174 | if is_mino_piece(action.piece.piece_type): 175 | try: 176 | next_quiz = store.quiz.next_if_end() 177 | operation = next_quiz.get_operation(action.piece.piece_type) 178 | store.quiz = next_quiz.operate(operation) 179 | 180 | except Exception as e: 181 | # print(e) 182 | 183 | # Not operate 184 | store.quiz = store.quiz.format() 185 | 186 | else: 187 | store.quiz = store.quiz.format() 188 | 189 | # process for data processing 190 | current_piece: Optional[Operation] = None 191 | 192 | if action.piece.piece_type is not Piece.EMPTY: 193 | current_piece = action.piece 194 | 195 | # page creation 196 | field: PageField 197 | if current_field_obj.changed or page_index == 0: 198 | # when there is a change in the field 199 | # When the field did not change, but it was the first page 200 | field = PageField() 201 | store.ref_index.field = page_index 202 | else: 203 | # when there is no change in the field 204 | field = PageField(ref=store.ref_index.field) 205 | 206 | pages.append(Page( 207 | page_index, 208 | current_field_obj.field, 209 | Mino.mino_from(Operation(parse_piece_name(current_piece.piece_type), 210 | parse_rotation_name(current_piece.rotation), 211 | current_piece.x, 212 | current_piece.y)) 213 | if current_piece is not None else None, 214 | comment.text if comment.text is not None else store.last_comment_text, 215 | Flags(action.lock, action.mirror, action.colorize, action.rise, quiz), 216 | Refs(field=field.ref, comment=comment.ref) 217 | )) 218 | 219 | """ callback( 220 | currentFieldObj.field.copy() 221 | , currentPiece 222 | , store.quiz !== undefined ? store.quiz.format().toString() : store.lastCommentText, 223 | ) 224 | """ 225 | 226 | page_index += 1 227 | 228 | if action.lock: 229 | if is_mino_piece(action.piece.piece_type): 230 | current_field_obj.field.fill(action.piece) 231 | 232 | current_field_obj.field.clear_line() 233 | 234 | if action.rise: 235 | current_field_obj.field.rise_garbage() 236 | 237 | if action.mirror: 238 | current_field_obj.field.mirror() 239 | 240 | prev_field = current_field_obj.field 241 | 242 | return pages 243 | -------------------------------------------------------------------------------- /src/py_fumen/inner_field.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import annotations 4 | from dataclasses import dataclass 5 | from typing import List, Optional 6 | from math import floor 7 | from copy import deepcopy 8 | 9 | from .defines import InnerOperation, parse_piece, Piece, Rotation 10 | from .constants import FieldConstants 11 | 12 | class PieceException(Exception): 13 | pass 14 | 15 | class RotationException(Exception): 16 | pass 17 | 18 | def get_pieces(piece: Piece) -> List[List[int]]: 19 | if piece is Piece.I: 20 | return [[0, 0], [-1, 0], [1, 0], [2, 0]] 21 | if piece is Piece.T: 22 | return [[0, 0], [-1, 0], [1, 0], [0, 1]] 23 | if piece is Piece.O: 24 | return [[0, 0], [1, 0], [0, 1], [1, 1]] 25 | if piece is Piece.L: 26 | return [[0, 0], [-1, 0], [1, 0], [1, 1]] 27 | if piece is Piece.J: 28 | return [[0, 0], [-1, 0], [1, 0], [-1, 1]] 29 | if piece is Piece.S: 30 | return [[0, 0], [-1, 0], [0, 1], [1, 1]] 31 | if piece is Piece.Z: 32 | return [[0, 0], [1, 0], [0, 1], [-1, 1]] 33 | 34 | raise PieceException('Unsupported piece') 35 | 36 | def rotate_right(positions: List[List[int]]) -> List[List[int]]: 37 | return [[current[1], -current[0]] for current in positions] 38 | 39 | def rotate_left(positions: List[List[int]]) -> List[List[int]]: 40 | return [[-current[1], current[0]] for current in positions] 41 | 42 | def rotate_reverse(positions: List[List[int]]) -> List[List[int]]: 43 | return [[-current[0], -current[1]] for current in positions] 44 | 45 | def get_blocks(piece: Piece, rotation: Rotation) -> List[List[int]]: 46 | blocks = get_pieces(piece) 47 | if rotation is Rotation.SPAWN: 48 | return blocks 49 | if rotation is Rotation.LEFT: 50 | return rotate_left(blocks) 51 | if rotation is Rotation.REVERSE: 52 | return rotate_reverse(blocks) 53 | if rotation is Rotation.RIGHT: 54 | return rotate_right(blocks) 55 | 56 | raise RotationException('Unsupported rotation') 57 | 58 | def get_block_positions(piece: Piece, rotation: Rotation, x: int, y: int) -> List[List[int]]: 59 | return [[position[0]+x, position[1]+y] for position in get_blocks(piece, rotation)] 60 | 61 | @dataclass 62 | class XY(): 63 | x: int 64 | y: int 65 | 66 | def get_block_xys(piece: Piece, rotation: Rotation, x: int, y: int) -> List[XY]: 67 | return [XY(position[0]+x, position[1]+y) for position in get_blocks(piece, rotation)] 68 | 69 | class PlayField(): 70 | __pieces: List[Piece] 71 | __length: int 72 | 73 | def __init__(self, pieces: List[Piece] = None, length: int = FieldConstants.PLAY_BLOCKS): 74 | if pieces is not None: 75 | self.__pieces = pieces 76 | else: 77 | self.__pieces = [Piece.EMPTY for i in range(length)] 78 | 79 | self.__length = length 80 | 81 | def get(self, x: int, y: int) -> Piece: 82 | return self.__pieces[x + y * FieldConstants.WIDTH] 83 | 84 | def add_offset(self, x: int, y: int, value: int): 85 | self.__pieces[x + y * FieldConstants.WIDTH] = Piece(self.__pieces[x + y * FieldConstants.WIDTH].value+value) 86 | return 87 | 88 | def set_at(self, index: int, piece: Piece): 89 | self.__pieces[index] = piece 90 | return 91 | 92 | def set(self, x: int, y: int, piece: Piece): 93 | self.set_at(x + y * FieldConstants.WIDTH, piece) 94 | 95 | class BlockCountException(Exception): 96 | pass 97 | 98 | @staticmethod 99 | def load_inner(blocks: str, length: Optional[int] = None) -> PlayField: 100 | inner_len = length if length is not None else len(blocks) 101 | if inner_len % 10 != 0: 102 | raise PlayField.BlockCountException('Num of blocks in field should be mod 10') 103 | 104 | field = PlayField(length=length) if length is not None else PlayField() 105 | 106 | for index in range(inner_len): 107 | block = blocks[index] 108 | field.set(index % 10, floor((inner_len - index - 1) / 10), parse_piece(block)) 109 | 110 | return field 111 | 112 | @staticmethod 113 | def load(lines: List[str]) -> PlayField: 114 | blocks = ''.join(lines).strip() 115 | return PlayField.load_inner(blocks) 116 | 117 | @staticmethod 118 | def load_minify(lines: List[str]) -> PlayField: 119 | blocks = ''.join(lines).strip() 120 | return PlayField.load_inner(blocks, len(blocks)) 121 | 122 | def fill(self, operation: InnerOperation): 123 | blocks = get_blocks(operation.piece_type, operation.rotation) 124 | for block in blocks: 125 | nx, ny = operation.x + block[0], operation.y + block[1] 126 | self.set(nx, ny, operation.piece_type) 127 | 128 | def fill_all(self, positions: List[XY], piece_type: Piece): 129 | for xy in positions: 130 | self.set(xy.x, xy.y, piece_type) 131 | 132 | def clear_line(self): 133 | new_field = deepcopy(self.__pieces) 134 | top = int(len(self.__pieces) / FieldConstants.WIDTH - 1) 135 | 136 | for y in range(top, -1, -1): 137 | line = self.__pieces[y * FieldConstants.WIDTH : (y + 1) * FieldConstants.WIDTH] 138 | is_filled = Piece.EMPTY not in line 139 | if is_filled: 140 | bottom = new_field[0:y * FieldConstants.WIDTH] 141 | over = new_field[(y + 1) * FieldConstants.WIDTH:] 142 | new_field = bottom + over + ([Piece.EMPTY] * FieldConstants.WIDTH) 143 | 144 | self.__pieces = new_field 145 | 146 | def up(self, block_up: PlayField): 147 | self.__pieces = (block_up.__pieces + (self.__pieces))[0:self.__length] 148 | 149 | def mirror(self): 150 | new_field: List[Piece] = [] 151 | for y in range(len(self.__pieces)): 152 | line = self.__pieces[y * FieldConstants.WIDTH : (y + 1) * FieldConstants.WIDTH] 153 | line.reverse() 154 | for obj in line: 155 | new_field.append(obj) 156 | 157 | self.__pieces = new_field 158 | 159 | def shift_to_left(self): 160 | height = int(self.__pieces.length / 10) 161 | for y in range(height): 162 | for x in range(FieldConstants.WIDTH - 1): 163 | self.__pieces[x + y * FieldConstants.WIDTH] = self.__pieces[x + 1 + y * FieldConstants.WIDTH] 164 | self.__pieces[9 + y * FieldConstants.WIDTH] = Piece.EMPTY 165 | 166 | def shift_to_right(self): 167 | height = self.__pieces.length / 10 168 | for y in range(height): 169 | for x in range(FieldConstants.WIDTH - 1, -1, -1): 170 | self.__pieces[x + y * FieldConstants.WIDTH] = self.__pieces[x - 1 + y * FieldConstants.WIDTH] 171 | 172 | self.__pieces[y * FieldConstants.WIDTH] = Piece.EMPTY 173 | 174 | def shift_to_up(self): 175 | blanks = [Piece.EMPTY] * FieldConstants.WIDTH 176 | self.__pieces = (blanks + self.__pieces)[0 : self.__length] 177 | 178 | def shift_to_bottom(self): 179 | blanks = [Piece.EMPTY] * FieldConstants.WIDTH 180 | self.__pieces = self.__pieces[10:self.__length] + blanks 181 | 182 | def to_array(self) -> List[Piece]: 183 | return deepcopy(self.__pieces) 184 | 185 | def num_of_blocks(self) -> int: 186 | return len(self.__pieces) 187 | 188 | def copy(self) -> PlayField: 189 | return PlayField(pieces = deepcopy(self.__pieces), length = self.__length) 190 | 191 | def to_shallow_array(self) -> List[Piece]: 192 | return self.__pieces 193 | 194 | def clear_all(self): 195 | self.__pieces = [Piece.EMPTY] * len(self.__pieces) 196 | 197 | def equals(self, other: PlayField) -> bool: 198 | if len(self.__pieces) != len(other.__pieces): 199 | return False 200 | 201 | for index in range(len(self.__pieces)): 202 | if self.__pieces[index] != other.__pieces[index]: 203 | return False 204 | 205 | return True 206 | 207 | class InnerField(): 208 | __field: PlayField 209 | __garbage: PlayField 210 | 211 | @staticmethod 212 | def __create(length: int) -> PlayField: 213 | return PlayField(length=length) 214 | 215 | def __init__(self, field: Optional[PlayField] = None, garbage: Optional[PlayField] = None): 216 | if field is None: 217 | field = self.__create(FieldConstants.PLAY_BLOCKS) 218 | if garbage is None: 219 | garbage = self.__create(FieldConstants.WIDTH) 220 | self.__field = field 221 | self.__garbage = garbage 222 | 223 | def fill(self, operation: InnerOperation): 224 | self.__field.fill(operation) 225 | 226 | def fill_all(self, positions: List[XY], type: Piece): 227 | self.__field.fill_all(positions, type) 228 | 229 | def can_fill(self, piece: Piece, rotation: Rotation, x: int, y: int) -> bool: 230 | positions = get_block_positions(piece, rotation, x, y) 231 | 232 | return all(0 <= px and px < 10 and 0 <= py and py < FieldConstants.HEIGHT and self.get_number_at(px, py) is Piece.EMPTY for px, py in positions) 233 | 234 | def can_fill_all(self, positions: List[XY]) -> bool: 235 | return all(0 <= position.x and position.x < 10 and 0 <= position.y and position.y < FieldConstants.HEIGHT and self.get_number_at(position.x, position.y) is Piece.EMPTY for position in positions) 236 | 237 | def is_on_ground(self, piece: Piece, rotation: Rotation, x: int, y: int): 238 | return not self.can_fill(piece, rotation, x, y - 1) 239 | 240 | def clear_line(self): 241 | self.__field.clear_line() 242 | 243 | def rise_garbage(self): 244 | self.__field.up(self.__garbage) 245 | self.__garbage.clear_all() 246 | 247 | def mirror(self): 248 | self.__field.mirror() 249 | 250 | def shift_to_left(self): 251 | self.__field.shift_to_left() 252 | 253 | def shift_to_right(self): 254 | self.__field.shift_to_right() 255 | 256 | def shift_to_up(self): 257 | self.__field.shift_to_up() 258 | 259 | def shift_to_bottom(self): 260 | self.__field.shift_to_bottom() 261 | 262 | def copy(self) -> InnerField: 263 | return InnerField(field=self.__field.copy(), garbage=self.__garbage.copy()) 264 | 265 | def equals(self, other: InnerField) -> bool: 266 | return self.__field.equals(other.field) and self.__garbage.equals(other.garbage) 267 | 268 | def add_number(self, x: int, y: int, value: int): 269 | if 0 <= y: 270 | self.__field.add_offset(x, y, value) 271 | else: 272 | self.__garbage.add_offset(x, -(y + 1), value) 273 | 274 | def set_number_field_at(self, index: int, value: int): 275 | self.__field.set_at(index, value) 276 | 277 | def set_number_garbage_at(self, index: int, value: int): 278 | self.__garbage.set_at(index, value) 279 | 280 | def set_number_at(self, x: int, y: int, value: int): 281 | self.__field.set(x, y, value) if 0 <= y else self.__garbage.set(x, -(y + 1), value) 282 | 283 | def get_number_at(self, x: int, y: int) -> Piece: 284 | return self.__field.get(x, y) if 0 <= y else self.__garbage.get(x, -(y + 1)) 285 | 286 | def get_number_at_index(self, index: int, is_field: bool) -> Piece: 287 | if is_field: 288 | return self.get_number_at(index % 10, floor(index / 10)) 289 | return self.get_number_at(index % 10, -(floor(index / 10) + 1)) 290 | 291 | def to_field_number_array(self) -> List[Piece]: 292 | return self.__field.to_array() 293 | 294 | def to_garbage_number_array(self) -> List[Piece]: 295 | return self.__garbage.to_array() --------------------------------------------------------------------------------