Python Turtle Write Value in Containing Box
Asked Answered
V

1

6

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()
Vitriform answered 2/8, 2020 at 10:50 Comment(7)
If the turtle moves, do the numbers have to move too or will they be fixed?Selfconfessed
Well ideally each object is a "single unit" - e.g. a square with a number in it, so the number would move with the turtle. I have solved this before by making tile images, but that seems silly when we have the square shape and the write method.Vitriform
So when the turtle writes something it'll remain there. (i.e. if tile.show_value(5), tile.forward(10) the 5 will remain put). I think what you want to do is a custom shape?Selfconfessed
The opposite. A square cell with a "5" in it. The cell moves, the "5" moves with it. If I want to change the "5" to a "3", no problem, call my_tile.set_value("3").Vitriform
I see, but when you write something on the screen it stays at that position. So what I think you can do is have tile be essentially two turtles, where one is a turtle that has shape square and the other is the corresponding number. and when tile goes forward you make both go forward tooSelfconfessed
Maybe, so my_turtle.text_turtle could be a turtle which is a property of the Tile class which inherits from turtle.Turtle.Vitriform
Exactly. so Tile can be a turtle, but I would recommend it being a separate class with two turtles inside of it. and overriding the tile.shapesize, fillcolor, setx, etc.Selfconfessed
E
5

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

    def register_image(self, string):
        width, height = self.draw.textsize(string, font=self.font)
        image = Image.new("RGB", (width + self.outline_size*2, height + self.outline_size*2), self.background_color)
        draw = ImageDraw.Draw(image)
        tile_size = (width + self.outline_size, height + 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 tileset import TileSet
from turtle import Screen
from functools import partial
from random import shuffle

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] == 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

Ermin answered 14/8, 2020 at 18:5 Comment(2)
OK, thanks. That is surprisingly involved, but great to have it working. I had a bit of trouble with fonts. I found that DEFAULT_FONT_FILE = "C:\Windows\Fonts\courbd.ttf" was OK, but many other fonts put the tiles out of alignment with each other. Is there a way to know which fonts I can use which will align correctly please?Vitriform
@RobinAndrews, it depends on what you want to do with the tiles. In a situation like the puzzle above, we want a fixed width font which is why I chose Courier. (Which I'm guessing is what you ended up with.) If I were using the tiles to emulate those fridge word magnets that people assemble into sentences, then I'd probably want a typical variable width font.Ermin

© 2022 - 2024 — McMap. All rights reserved.