|
import streamlit as st |
|
import random |
|
import os |
|
from typing import List, Tuple, Dict, Optional |
|
|
|
|
|
os.environ['STREAMLIT_CONFIG_DIR'] = '/tmp/.streamlit' |
|
os.makedirs('/tmp/.streamlit', exist_ok=True) |
|
|
|
|
|
st.set_page_config( |
|
page_title="Puzzle8 Game", |
|
page_icon="π§©", |
|
layout="centered", |
|
initial_sidebar_state="collapsed" |
|
) |
|
|
|
|
|
SOLVED_STATE = [1, 2, 3, 4, 5, 6, 7, 8, 0] |
|
|
|
class Puzzle8Game: |
|
def __init__(self): |
|
self.puzzle = None |
|
self.initial_puzzle = None |
|
self.moves = 0 |
|
self.game_won = False |
|
|
|
@staticmethod |
|
def pos_to_coords(pos: int) -> Dict[str, int]: |
|
"""Convert position (1-9) to row, col coordinates (1-3)""" |
|
return {"row": (pos - 1) // 3 + 1, "col": (pos - 1) % 3 + 1} |
|
|
|
@staticmethod |
|
def coords_to_pos(row: int, col: int) -> int: |
|
"""Convert row, col coordinates to position""" |
|
return (row - 1) * 3 + col |
|
|
|
@staticmethod |
|
def is_valid_position(row: int, col: int) -> bool: |
|
"""Check if position is within 3x3 grid""" |
|
return 1 <= row <= 3 and 1 <= col <= 3 |
|
|
|
def create_solvable_puzzle(self, moves: int = 100) -> List[int]: |
|
"""Create a solvable puzzle by shuffling from solved state""" |
|
puzzle = SOLVED_STATE.copy() |
|
blank_pos = 9 |
|
|
|
|
|
for _ in range(moves): |
|
coords = self.pos_to_coords(blank_pos) |
|
valid_moves = [] |
|
|
|
|
|
directions = [ |
|
{"row": -1, "col": 0}, |
|
{"row": 1, "col": 0}, |
|
{"row": 0, "col": -1}, |
|
{"row": 0, "col": 1} |
|
] |
|
|
|
for direction in directions: |
|
new_row = coords["row"] + direction["row"] |
|
new_col = coords["col"] + direction["col"] |
|
|
|
if self.is_valid_position(new_row, new_col): |
|
valid_moves.append(self.coords_to_pos(new_row, new_col)) |
|
|
|
if valid_moves: |
|
move_pos = random.choice(valid_moves) |
|
|
|
|
|
puzzle[blank_pos - 1], puzzle[move_pos - 1] = puzzle[move_pos - 1], puzzle[blank_pos - 1] |
|
blank_pos = move_pos |
|
|
|
|
|
if puzzle == SOLVED_STATE: |
|
return self.create_solvable_puzzle(moves + 20) |
|
|
|
return puzzle |
|
|
|
def is_solved(self, puzzle: List[int]) -> bool: |
|
"""Check if puzzle is solved""" |
|
return puzzle == SOLVED_STATE |
|
|
|
def get_blank_position(self, puzzle: List[int]) -> int: |
|
"""Get position of blank tile (value 0)""" |
|
return puzzle.index(0) + 1 |
|
|
|
def get_valid_moves(self, blank_pos: int) -> List[int]: |
|
"""Get valid moves for current blank position""" |
|
coords = self.pos_to_coords(blank_pos) |
|
valid_moves = [] |
|
|
|
directions = [ |
|
{"row": -1, "col": 0}, |
|
{"row": 1, "col": 0}, |
|
{"row": 0, "col": -1}, |
|
{"row": 0, "col": 1} |
|
] |
|
|
|
for direction in directions: |
|
new_row = coords["row"] + direction["row"] |
|
new_col = coords["col"] + direction["col"] |
|
|
|
if self.is_valid_position(new_row, new_col): |
|
valid_moves.append(self.coords_to_pos(new_row, new_col)) |
|
|
|
return valid_moves |
|
|
|
def move_tile(self, puzzle: List[int], tile_position: int) -> Dict: |
|
"""Move tile with validation""" |
|
blank_pos = self.get_blank_position(puzzle) |
|
valid_moves = self.get_valid_moves(blank_pos) |
|
|
|
if tile_position in valid_moves: |
|
|
|
new_puzzle = puzzle.copy() |
|
new_puzzle[blank_pos - 1] = puzzle[tile_position - 1] |
|
new_puzzle[tile_position - 1] = 0 |
|
|
|
return {"puzzle": new_puzzle, "moved": True} |
|
|
|
return {"puzzle": puzzle, "moved": False} |
|
|
|
def new_game(self): |
|
"""Start a new game""" |
|
new_puzzle = self.create_solvable_puzzle() |
|
self.puzzle = new_puzzle |
|
self.initial_puzzle = new_puzzle.copy() |
|
self.moves = 0 |
|
self.game_won = False |
|
|
|
def reset_game(self): |
|
"""Reset to initial puzzle state""" |
|
if self.initial_puzzle: |
|
self.puzzle = self.initial_puzzle.copy() |
|
self.moves = 0 |
|
self.game_won = False |
|
|
|
def make_move(self, position: int): |
|
"""Make a move and update game state""" |
|
if not self.game_won and self.puzzle: |
|
result = self.move_tile(self.puzzle, position) |
|
|
|
if result["moved"]: |
|
self.puzzle = result["puzzle"] |
|
self.moves += 1 |
|
|
|
|
|
if self.is_solved(self.puzzle): |
|
self.game_won = True |
|
|
|
|
|
if 'game' not in st.session_state: |
|
st.session_state.game = Puzzle8Game() |
|
st.session_state.game.new_game() |
|
|
|
|
|
st.markdown(""" |
|
<style> |
|
/* Hide Streamlit elements */ |
|
#MainMenu {visibility: hidden;} |
|
footer {visibility: hidden;} |
|
header {visibility: hidden;} |
|
.stDeployButton {display: none;} |
|
|
|
.main > div { |
|
padding-top: 1rem; |
|
padding-bottom: 1rem; |
|
} |
|
|
|
.stApp { |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
min-height: 100vh; |
|
} |
|
|
|
.game-container { |
|
background: rgba(255, 255, 255, 0.95); |
|
border-radius: 16px; |
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1); |
|
padding: 30px; |
|
margin: 20px auto; |
|
text-align: center; |
|
max-width: 600px; |
|
backdrop-filter: blur(10px); |
|
} |
|
|
|
.puzzle-grid { |
|
display: grid; |
|
grid-template-columns: repeat(3, 100px); |
|
grid-template-rows: repeat(3, 100px); |
|
gap: 8px; |
|
justify-content: center; |
|
margin: 30px auto; |
|
padding: 20px; |
|
background: rgba(0,0,0,0.05); |
|
border-radius: 8px; |
|
max-width: 350px; |
|
} |
|
|
|
.stButton > button { |
|
width: 100px !important; |
|
height: 100px !important; |
|
border: none !important; |
|
border-radius: 8px !important; |
|
background: linear-gradient(145deg, #2196F3, #1976D2) !important; |
|
color: white !important; |
|
font-size: 24px !important; |
|
font-weight: 700 !important; |
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important; |
|
margin: 0 !important; |
|
padding: 0 !important; |
|
} |
|
|
|
.stButton > button:hover { |
|
transform: translateY(-2px) scale(1.02) !important; |
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15) !important; |
|
} |
|
|
|
.empty-tile { |
|
width: 100px; |
|
height: 100px; |
|
background: rgba(255,255,255,0.8); |
|
border: 2px dashed #ccc; |
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); |
|
border-radius: 8px; |
|
margin: 0; |
|
} |
|
|
|
.moves-counter { |
|
background: white; |
|
padding: 20px; |
|
border-radius: 8px; |
|
margin: 20px 0; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
|
border-left: 4px solid #2196F3; |
|
} |
|
|
|
.congratulations-card { |
|
background: linear-gradient(145deg, #4CAF50, #388E3C); |
|
color: white; |
|
padding: 25px; |
|
border-radius: 8px; |
|
margin: 20px 0; |
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15); |
|
animation: celebration 0.6s ease; |
|
} |
|
|
|
@keyframes celebration { |
|
0% { |
|
opacity: 0; |
|
transform: translateY(-20px) scale(0.9); |
|
} |
|
50% { |
|
transform: translateY(-5px) scale(1.05); |
|
} |
|
100% { |
|
opacity: 1; |
|
transform: translateY(0) scale(1); |
|
} |
|
} |
|
|
|
.moves-value { |
|
font-size: 36px; |
|
font-weight: bold; |
|
color: #2196F3; |
|
margin-bottom: 5px; |
|
} |
|
|
|
.moves-label { |
|
font-size: 14px; |
|
color: #666; |
|
text-transform: uppercase; |
|
letter-spacing: 1px; |
|
} |
|
|
|
.congratulations-title { |
|
font-size: 28px; |
|
font-weight: bold; |
|
margin-bottom: 10px; |
|
} |
|
|
|
.congratulations-subtitle { |
|
font-size: 18px; |
|
font-weight: 600; |
|
opacity: 0.9; |
|
} |
|
|
|
.control-button > button { |
|
width: 140px !important; |
|
height: 45px !important; |
|
border-radius: 8px !important; |
|
font-weight: 600 !important; |
|
font-size: 16px !important; |
|
text-transform: uppercase !important; |
|
letter-spacing: 0.5px !important; |
|
margin: 5px !important; |
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; |
|
} |
|
|
|
.control-button > button:hover { |
|
transform: translateY(-2px) !important; |
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15) !important; |
|
} |
|
|
|
.instructions { |
|
background: white; |
|
border-radius: 8px; |
|
padding: 25px; |
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
|
text-align: left; |
|
margin: 20px 0; |
|
} |
|
|
|
.goal-grid { |
|
display: grid; |
|
grid-template-columns: repeat(3, 40px); |
|
gap: 4px; |
|
justify-content: center; |
|
margin: 15px 0; |
|
} |
|
|
|
.goal-tile { |
|
width: 40px; |
|
height: 40px; |
|
background: #2196F3; |
|
color: white; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
border-radius: 4px; |
|
font-weight: bold; |
|
font-size: 14px; |
|
} |
|
|
|
.goal-empty { |
|
background: #f0f0f0; |
|
border: 2px dashed #ccc; |
|
} |
|
|
|
/* Mobile responsive */ |
|
@media (max-width: 768px) { |
|
.puzzle-grid { |
|
grid-template-columns: repeat(3, 80px); |
|
grid-template-rows: repeat(3, 80px); |
|
max-width: 280px; |
|
} |
|
|
|
.stButton > button { |
|
width: 80px !important; |
|
height: 80px !important; |
|
font-size: 20px !important; |
|
} |
|
|
|
.empty-tile { |
|
width: 80px; |
|
height: 80px; |
|
} |
|
|
|
.game-container { |
|
padding: 20px; |
|
margin: 10px; |
|
} |
|
} |
|
</style> |
|
""", unsafe_allow_html=True) |
|
|
|
def main(): |
|
|
|
st.markdown(""" |
|
<div class="game-container"> |
|
<h1 style='text-align: center; background: linear-gradient(145deg, #2196F3, #1976D2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-weight: 800; font-size: 3rem; margin-bottom: 30px;'> |
|
π§© Puzzle8 |
|
</h1> |
|
""", unsafe_allow_html=True) |
|
|
|
|
|
game = st.session_state.game |
|
|
|
|
|
if game.game_won: |
|
st.markdown(f""" |
|
<div class="congratulations-card"> |
|
<div class="congratulations-title">π Congratulations!</div> |
|
<div class="congratulations-subtitle">Puzzle solved in {game.moves} moves!</div> |
|
</div> |
|
""", unsafe_allow_html=True) |
|
else: |
|
st.markdown(f""" |
|
<div class="moves-counter"> |
|
<div class="moves-value">{game.moves}</div> |
|
<div class="moves-label">Moves</div> |
|
</div> |
|
""", unsafe_allow_html=True) |
|
|
|
|
|
st.markdown('<div class="puzzle-grid">', unsafe_allow_html=True) |
|
|
|
|
|
for row in range(3): |
|
cols = st.columns(3) |
|
for col in range(3): |
|
i = row * 3 + col |
|
|
|
with cols[col]: |
|
if game.puzzle and len(game.puzzle) > i: |
|
value = game.puzzle[i] |
|
|
|
if value == 0: |
|
|
|
st.markdown('<div class="empty-tile"></div>', unsafe_allow_html=True) |
|
else: |
|
|
|
if st.button(str(value), key=f"tile_{i+1}"): |
|
game.make_move(i + 1) |
|
st.rerun() |
|
|
|
st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
|
|
col1, col2 = st.columns(2) |
|
|
|
with col1: |
|
st.markdown('<div class="control-button">', unsafe_allow_html=True) |
|
if st.button("π² New Game", key="new_game", type="primary"): |
|
game.new_game() |
|
st.rerun() |
|
st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
with col2: |
|
st.markdown('<div class="control-button">', unsafe_allow_html=True) |
|
if st.button("π Reset", key="reset_game", type="secondary"): |
|
game.reset_game() |
|
st.rerun() |
|
st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
|
|
with st.expander("π― How to Play", expanded=False): |
|
st.markdown(""" |
|
**Goal:** Arrange numbers 1-8 in order with the empty space in the bottom-right corner. |
|
|
|
**Rules:** |
|
- Click on tiles adjacent to the empty space to move them |
|
- Only tiles next to the empty space can be moved |
|
- Try to solve the puzzle in as few moves as possible! |
|
|
|
**Target arrangement:** |
|
""") |
|
|
|
|
|
st.markdown(""" |
|
<div style="text-align: center;"> |
|
<div class="goal-grid"> |
|
<div class="goal-tile">1</div> |
|
<div class="goal-tile">2</div> |
|
<div class="goal-tile">3</div> |
|
<div class="goal-tile">4</div> |
|
<div class="goal-tile">5</div> |
|
<div class="goal-tile">6</div> |
|
<div class="goal-tile">7</div> |
|
<div class="goal-tile">8</div> |
|
<div class="goal-tile goal-empty"></div> |
|
</div> |
|
</div> |
|
""", unsafe_allow_html=True) |
|
|
|
st.markdown('</div>', unsafe_allow_html=True) |
|
|
|
if __name__ == "__main__": |
|
main() |