Search code examples
pythonturtle-graphics

Python Turtle Write Value in Containing Box


I want to be able to create some turtles which display values by subclassing turtle.Turtle.

These turtles should display their value as text centered in their own shape. I also want to be able to position the turtles with accuracy, so setting/determining their width and height relative to a given font size is important.

This is my attempt so far:

enter image description here

I think this answer is relevant: How to know the pixel size of a specific text on turtle graphics in python? but it is quite old, and the bounding box it draws around the text is not in the correct position using python 3.8.

import turtle

FONT_SIZE = 32

class Tile(turtle.Turtle):
    def __init__(self):
        super().__init__(shape="square")
        self.penup()
    
    def show_value(self, val):
        self.write(val, font=("Arial", FONT_SIZE, "bold"), align="center")


screen = turtle.Screen()
vals = [5, 7, 8, 2]
for i in range(len(vals)):
    tile = Tile()
    tile_size = (FONT_SIZE / 20)
    tile.shapesize(tile_size)
    tile.fillcolor("red" if i % 2 == 0 else "blue")
    tile.setx(i * FONT_SIZE)
    tile.show_value(vals[i])
turtle.done()

Solution

  • It would be very helpful to have Turtle Objects containing text such as integer values, which can be used to display a variety of puzzles and games, and can have their own click handlers attached.

    Here's the rub, and the (two) reason(s) that approaches using stamp() as suggested in other answers won't work. First, you can't click on a hidden turtle:

    from turtle import *
    
    def doit(x, y):
        print("Just do it!")
    
    yertle = Turtle()
    # comment out the following line if you want `onlick()` to work
    yertle.hideturtle()
    yertle.shape('square')
    yertle.stamp()
    yertle.onclick(doit)
    
    done()
    

    Stamps are not clickable entities. Second, you can't even click on a turtle that's behind ink left by this, or another, turtle:

    from turtle import *
    
    def doit(x, y):
        print("Just do it!")
    
    yertle = Turtle()
    yertle.shape('square')
    yertle.fillcolor('white')
    yertle.onclick(doit)
    
    myrtle = Turtle()
    myrtle.shape('turtle')
    myrtle.penup()
    myrtle.sety(-16)
    # comment out the following line if you want `onlick()` to work
    myrtle.write('X', align='center', font=('Courier', 32, 'bold'))
    myrtle.goto(100, 100)  # move myrtle out of the way of clicking
    
    done()
    

    If you click on the letter 'X', nothing happens unless you manage to hit a portion of the square just beyond the letter. My belief is that although we think of the 'X' as dead ink over our live turtle, at the tkinter level they are both similar, possibly both capable of receiving events, so one obscures the click on the other.

    So how can we do this? The approach I'm going to use is make a tile a turtle with an image where the images are generate by writing onto bitmaps:

    tileset.py

    from turtle import Screen, Turtle, Shape
    from PIL import Image, ImageDraw, ImageFont, ImageTk
    
    DEFAULT_FONT_FILE = "/Library/Fonts/Courier New Bold.ttf"  # adjust for your system
    DEFAULT_POINT_SIZE = 32
    DEFAULT_OUTLINE_SIZE = 1
    DEFAULT_OUTLINE_COLOR = 'black'
    DEFAULT_BACKGROUND_COLOR = 'white'
    
    class Tile(Turtle):
        def __init__(self, shape, size):
            super().__init__(shape)
            self.penup()
    
            self.size = size
    
        def tile_size(self):
            return self.size
    
    class TileSet():
    
        def __init__(self, font_file=DEFAULT_FONT_FILE, point_size=DEFAULT_POINT_SIZE, background_color=DEFAULT_BACKGROUND_COLOR, outline_size=DEFAULT_OUTLINE_SIZE, outline_color=DEFAULT_OUTLINE_COLOR):
            self.font = ImageFont.truetype(font_file, point_size)
            self.image = Image.new("RGB", (point_size, point_size))
            self.draw = ImageDraw.Draw(self.image)
    
            self.background_color = background_color
            self.outline_size = outline_size
            self.outline_color = outline_color
            self.point_size = point_size
    
        def register_image(self, string):
            width = self.draw.textlength(string, font=self.font)
            image = Image.new("RGB", (int(width) + self.outline_size*2, self.point_size + self.outline_size*2), self.background_color)
            draw = ImageDraw.Draw(image)
            tile_size = (width + self.outline_size, self.point_size + self.outline_size)
            draw.rectangle([(0, 0), tile_size], outline=self.outline_color)
            draw.text((0, 0), string, font=self.font, fill="#000000")
            photo_image = ImageTk.PhotoImage(image)
            shape = Shape("image", photo_image)
            Screen()._shapes[string] = shape  # underpinning, not published API
    
            return tile_size
    
        def make_tile(self, string):
            tile_size = self.register_image(string)
            return Tile(string, tile_size)
    

    Other than its image, the only differences a Tile instance has from a Turtle instance is an extra method tile_size() to return its width and height as generic turtles can't do this in the case of images. And a tile's pen is up at the start, instead of down.

    I've drawn on a couple of SO questions and answers:

    And while I'm at it, this answer has been updated to be more system independent:

    To demonstrate how my tile sets work, here's the well-know 15 puzzle implemented using them. It creates two tile sets, one with white backgrounds and one with red (pink) backgrounds:

    from turtle import Screen
    from functools import partial
    from random import shuffle
    from tileset import TileSet
    
    SIZE = 4
    OFFSETS = [(-1, 0), (0, -1), (1, 0), (0, 1)]
    
    def slide(tile, row, col, x, y):
        tile.onclick(None)  # disable handler inside handler
    
        for dy, dx in OFFSETS:
            try:
                if row + dy >= 0 <= col + dx and matrix[row + dy][col + dx] is None:
                    matrix[row][col] = None
                    row, col = row + dy, col + dx
                    matrix[row][col] = tile
                    width, height = tile.tile_size()
                    x, y = tile.position()
                    tile.setposition(x + dx * width, y - dy * height)
                    break
            except IndexError:
                pass
    
        tile.onclick(partial(slide, tile, row, col))
    
    screen = Screen()
    
    matrix = [[None for _ in range(SIZE)] for _ in range(SIZE)]
    
    white_tiles = TileSet(background_color='white')
    red_tiles = TileSet(background_color='pink')
    
    tiles = []
    parity = True
    
    for number in range(1, SIZE * SIZE):
        string = str(number).rjust(2)
        tiles.append(white_tiles.make_tile(string) if parity else red_tiles.make_tile(string))
        parity = not parity
    
        if number % SIZE == 0:
            parity = not parity
    
    shuffle(tiles)
    
    width, height = tiles[0].tile_size()
    offset_width, offset_height = width * 1.5, height * 1.5
    
    for row in range(SIZE):
        for col in range(SIZE):
            if row == SIZE - 1 == col:
                break
    
            tile = tiles.pop(0)
            width, height = tile.tile_size()
            tile.goto(col * width - offset_width, offset_height - row * height)
            tile.onclick(partial(slide, tile, row, col))
            matrix[row][col] = tile
    
    screen.mainloop()
    

    If you click on a number tile that's next to the blank space, it will move into the blank space, otherwise nothing happens. This code doesn't guarantee a solvable puzzle -- half won't be solvable due to the random shuffle. It's just a demonstration, the fine details of it, and the tiles themselves, are left to you.

    enter image description here