Interaktiv grafikk
- Første eksempel: tell antall tastetrykk
- Model-View-Controller
- Identifisering av tastetrykk
- Flytte en prikk med piltastene
- Flytte en prikk med museklikk
- Flytte en prikk med timer
- Endre hastighet for timer
- Sette timer på pause
- Brudd med MVC
- Eksempel: legg til og fjern prikker
- Eksempel: sprettende figur
- Eksempel: museklikk i rutenett
- Eksempel: knapper
Første eksempel: tell antall tastetrykk
from uib_inf100_graphics import *
def app_started(app):
app.counter = 0
def key_pressed(app, event):
app.counter += 1
def redraw_all(app, canvas):
canvas.create_text(app.width/2, app.height/2,
text=f'{app.counter} keypresses',
font='Arial 30 bold',
fill='black')
run_app(width=400, height=400)
Model-View-Controller
Når man skriver interaktive grafiske programmer, kan koden fort bli rotete og uoversiktelig. For å hjelpe oss å skrive oversiktelig kode det er mulig å feilsøke, benytter vi oss av et prinssipp som kalles model-view-controller (MVC). I dette paradigmet er det tre sentrale begreper:
- Modell. En modell er en samling med variabler og data som representerer tilstanden til programmet. I eksempelet over er objektet
app
modellen, og funksjonenapp_started
er ansvarlig for å opprette variablene i den. - Visning. Funksjoner for å tegne noe på skjermen, fortrinnsvis basert på variablene og dataen i modellen. I eksempelet over er det funksjonen
redraw_all
som er visningen. - Kontroller. Funksjoner som responderer på tastetrykk, museklikk, klokkeslag/timer eller andre hendelser og oppdaterer modellen på bakgrunn av dette. I eksempelet over er funksjonen
key_pressed
en kontroller, men det finnes også mange andre (for eksempel mouse_pressed og timer_fired som introduseres litt senere).
I vårt MVC-baserte rammeverk uib_inf100_graphics
må koden vi skriver forholde seg til følgende kjøreregler:
- Aldri gjør et kall til en kontroller-funksjon (f. eks. key_pressed, mouse_pressed, timer_fired) eller til redraw_all på egen hånd. Rammeverket gjør dette for deg automatisk. I eksempelet over, legg merke til at det eneste funksjonskallet vi gjør selv er til
run_app
. - Kontroller-funksjonene skal kun oppdatere modellen (app), de skal ikke oppdatere visningen.
- Visningen skal kun tegne ting på skjermen, den skal ikke endre på noe i modellen (app).
- Variabler i modellen (app) opprettes første gang i funksjonen app_started.
Dersom du bryter noen av disse reglene, kalles det brudd med MVC (engelsk: MVC violation). Hvis rammeverket vårt oppdager et slikt brudd, vil det umiddelbart stoppe programmet og vise en feilmelding.
Identifisering av tastetrykk
Tastetrykk kan være forskjellige. Vi kan se hvilken tast som ble trykket ved å se på verdien event.key
som er en streng som beskriver tasten. Under er et program som lar oss se hvilken streng dette er når vi trykker på en tast.
from uib_inf100_graphics import *
def app_started(app):
app.message = 'Press any key'
def key_pressed(app, event):
app.message = f"event.key == '{event.key}'"
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 40, text=app.message,
font='Arial 20 bold', fill='black')
key_names_text = '''Here are the legal event.key names:
* Keyboard key labels (letters, digits, punctuation)
* Arrow directions ('Up', 'Down', 'Left', 'Right')
* Whitespace ('Space', 'Enter', 'Tab', 'BackSpace')
* Other commands ('Delete', 'Escape')'''
y = 80
for line in key_names_text.splitlines():
canvas.create_text(app.width/2, y, text=line.strip(),
font='Arial 16', fill='black')
y += 28
run_app(width=500, height=250)
Flytte en prikk med piltastene
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
elif (event.key == 'Right'):
app.cx += 10
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
# I denne versjonen kan ikke ballen flytte seg ut av lerretet
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
if (app.cx - app.r < 0):
app.cx = app.r
elif (event.key == 'Right'):
app.cx += 10
if (app.cx + app.r > app.width):
app.cx = app.width - app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre',
fill='black')
canvas.create_text(app.width/2, 40,
text='Ballen kan ikke kan flytte seg ut av vinduet',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
# I denne versjonen kommer ballen tilbake på motsatt side
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
elif (event.key == 'Right'):
app.cx += 10
if (app.cx - app.r >= app.width):
app.cx = 0 - app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre',
fill='black')
canvas.create_text(app.width/2, 40,
text='Ballen kommer rundt på motsatt side',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
# I denne versjonen kan ballen bevege seg i to dimensjoner
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'): app.cx -= 10
elif (event.key == 'Right'): app.cx += 10
elif (event.key == 'Up'): app.cy -= 10
elif (event.key == 'Down'): app.cy += 10
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre/opp/ned',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
Flytte en prikk med museklikk
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def mouse_pressed(app, event):
app.cx = event.x
app.cy = event.y
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt ved å klikke med musen',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
Flytte en prikk med timer
Funksjonen timer_fired
demonstrert her regnes som en kontroller, selv om det ikke strengt tatt er brukererens handling som gjør at metoden kalles; i stedet er det rammeverket uib_inf100_graphics selv som «opptrer som en bruker» ved å periodisk kalle denne funksjonen med et fast intervall.
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def timer_fired(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Prikken flytter seg automatisk',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
Endre hastighet for timer
Som standard kalles funksjonen timer_fired
med et intervall på 100 millisekunder (dvs. 10 ganger i sekundet). Vi kan endre dette ved å endre på variabelen app.timer_delay
. Vi kan endre variabelens verdi i app_started
eller (for å dynamisk endre hastigheten) i en kontroller-funksjon.
from uib_inf100_graphics import *
def app_started(app):
app.timer_delay = 128 # milliseconds
app.cx = app.width/2
app.cy = app.height/2 + 15
app.r = 40
def key_pressed(app, event):
if event.key == "Up":
app.timer_delay *= 2
app.timer_delay = max(app.timer_delay, 1)
elif event.key == "Down":
app.timer_delay //= 2
def timer_fired(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text=f"{app.timer_delay=}",
fill='black')
canvas.create_text(app.width/2, 40,
text=f"Trykk pil opp/ned for å doble/halvere delay",
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
Legg merke til at hastigheten på animasjonen ikke endres vesentlig når vi kommer ned til delay-verdier i nærheten av 0. Det er fordi det på en vanlig datamaskin med moderne spesifikasjoner fremdeles tar et par millisekunder å faktisk tegne skjermbildet, og da betyr ventetiden vi har mellom hvert kall mindre og mindre.
Timeren i
uib_inf100_graphics
fungerer omtrent slik: først kallestimer_fired
, og umiddelbart etter kallet er ferdig, kallesredraw_all
. Når kallet til redraw_all er ferdig, venter timeren iapp.timer_delay
millisekunder, og begynner deretter på nytt. Tiden det tar mellom hvert nye kall til timer_fired kan derfor grovt sett regnes ut som
- tiden det tar å kalle timer_fired, pluss
- tiden det tar å kalle redraw_all, pluss
- antall millisekunder definert i app.timer_delay.
Ventetiden kan også påvirkes av andre forhold, slik som prosessorbelastningen din datamaskin er utsatt for av andre kontroller-funksjoner eller til og med av andre programmer som kjøres samtidig på datamaskinen.
Sette timer på pause
Nyttig for feilsøking av animasjoner!
from uib_inf100_graphics import *
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2 + 15
app.r = 40
app.paused = False
def timer_fired(app):
if not app.paused:
do_step(app)
def do_step(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def key_pressed(app, event):
if event.key == 'p':
app.paused = not app.paused
elif event.key == 'Space' and app.paused:
do_step(app)
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Prikken flytter seg automatisk',
fill='black')
canvas.create_text(app.width/2, 40,
text='Trykk p for å sette på pause',
fill='black')
canvas.create_text(app.width/2, 60,
text='Trykk mellomrom for å ta steg i pausen',
fill='black')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='darkGreen')
run_app(width=300, height=200)
Brudd med MVC
Vi kan ikke endre modellen i redraw_all
.
from uib_inf100_graphics import *
def app_started(app):
app.x = 42
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Et brudd med MVC',
fill='black')
app.x = 10 # Her er bruddet! Ikke lov å endre modellen i visningen
run_app(width=300, height=200)
from uib_inf100_graphics import *
def app_started(app):
app.x = [42, 43]
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Også et brudd med MVC',
fill='black')
app.x[0] = 99 # Her er bruddet! Ikke lov å mutere noe i modellen her
run_app(width=300, height=200)
from uib_inf100_graphics import *
def app_started(app):
app.x = [42, 43]
def key_pressed(app, event):
mutany(app) # Det er ikke MVC-brudd når mutany kalles fra en kontroller
def mutany(app):
app.x.append(42) # Her skjer selve MVC-bruddet
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Enda et brudd med MVC!',
fill='black')
canvas.create_text(app.width/2, 40,
text='Trykk på en tast et par ganger',
fill='black')
canvas.create_text(app.width/2, 60,
text=f'{app.x=}',
fill='black')
if len(app.x) == 5:
mutany(app) # Under dette kallet skjer det et MVC-brudd
run_app(width=300, height=200)
Legg merke til at feilmeldingen (se under) ikke spesifiserer i hvilken funksjon bruddet skjer (nemlig i mutany -funksjonen), bare at det skjedde under utførelsen av redraw_all. Når du får en slik feilmelding, må du altså også undersøke at ingen av hjelpefunksjonene som benyttes muterer modellen.
Traceback (most recent call last):
No traceback available. Error occurred in redraw_all.
Exception: MVC Violation: you may not change the app state (the model) in redraw_all (the view)
Eksempel: legg til og fjern prikker
from uib_inf100_graphics import *
def app_started(app):
app.circle_centers = [ ]
def mouse_pressed(app, event):
new_circle_center = (event.x, event.y)
app.circle_centers.append(new_circle_center)
def key_pressed(app, event):
if (event.key == 'd'):
if (len(app.circle_centers) > 0):
app.circle_centers.pop(0)
else:
print('Ingen flere prikker å fjerne!')
def redraw_all(app, canvas):
# tegn prikkene
for circle_center in app.circle_centers:
(cx, cy) = circle_center
r = 20
canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='cyan')
# tegn teksten
canvas.create_text(app.width/2, 20,
text='Eksempel: legg til og fjern prikker',
fill='black')
canvas.create_text(app.width/2, 40,
text='Museklikk oppretter prikker',
fill='black')
canvas.create_text(app.width/2, 60,
text='Trykk på "d" for å fjerne prikker',
fill='black')
run_app(width=400, height=400)
Eksempel: sprettende figur
from uib_inf100_graphics import *
def app_started(app):
app.square_left = app.width//2
app.square_top = app.height//2
app.square_size = 25
app.dx = -4
app.dy = 5
app.is_paused = False
app.timer_delay = 25 # millisekunder
def key_pressed(app, event):
if event.key == "p":
app.is_paused = not app.is_paused
elif event.key == "s":
do_step(app)
def timer_fired(app):
if not app.is_paused:
do_step(app)
def do_step(app):
# Flytt horisontalt
app.square_left += app.dx
# Sjekk om firkanten har gått utenfor lerretet, og hvis ja, snu
# retning; men flytt også firkanten til kanten (i stedet for å gå
# forbi). Merk: det finnes andre, mer sofistikerte måter å håndtere
# at rektangelet går forbi kanten...
if app.square_left < 0:
# snu retningen!
app.square_left = 0
app.dx = -app.dx
elif app.square_left > app.width - app.square_size:
app.square_left = app.width - app.square_size
app.dx = -app.dx
# Flytt vertikalt på samme måte
app.square_top += app.dy
if app.square_top < 0:
# snu retningen!
app.square_top = 0
app.dy = -app.dy
elif app.square_top > app.height - app.square_size:
app.square_top = app.height - app.square_size
app.dy = -app.dy
def redraw_all(app, canvas):
# tegn firkanten
canvas.create_rectangle(
app.square_left,
app.square_top,
app.square_left + app.square_size,
app.square_top + app.square_size,
fill="yellow",
)
# tegn teksten
canvas.create_text(
app.width/2, 20,
text="Trykk 'p' for å sette på pause",
)
canvas.create_text(
app.width/2, 40,
text="Trykk 's' for å gjør et enkelt steg",
)
run_app(width=400, height=150)
Eksempel: museklikk i rutenett
from uib_inf100_graphics import *
def app_started(app):
app.rows = 5
app.cols = 8
app.margin = 50 # margin rundt rutenettet
app.selection = (-1, -1) # (row, col) for valgt rute, (-1,-1) for ingen
def point_in_grid(app, x, y):
# returner True hvis piksel-koordinatet (x, y) er på innsiden av
# rutenettet slik det blir tegnet i visningen.
return ((app.margin <= x <= app.width-app.margin) and
(app.margin <= y <= app.height-app.margin))
def get_cell(app, x, y):
# "visning-til-modell"
# returnerer (row, col) for ruten hvor piksel-koordnatet (x, y) hører
# hjemme, eller (-1, -1) hvis koodinatet er utenfor rutenettet
if (not point_in_grid(app, x, y)):
return (-1, -1)
grid_width = app.width - 2*app.margin
grid_height = app.height - 2*app.margin
cell_width = grid_width / app.cols
cell_height = grid_height / app.rows
# Merk: vi trenger å konvertere til int her; det er ikke
# tilstrekkelig å benytte //, siden x, y, eller app.margin kan
# være flyttall, og da vil også // returnere flyttall
row = int((y - app.margin) / cell_height)
col = int((x - app.margin) / cell_width)
return (row, col)
def get_cell_bounds(app, row, col):
# "modell-til-visning"
# returnerer (x0, y0, x1, y1), piksel-koordinater for hjørnene til
# den gitte ruten
grid_width = app.width - 2*app.margin
grid_height = app.height - 2*app.margin
column_width = grid_width / app.cols
row_height = grid_height / app.rows
x0 = app.margin + col * column_width
x1 = app.margin + (col+1) * column_width
y0 = app.margin + row * row_height
y1 = app.margin + (row+1) * row_height
return (x0, y0, x1, y1)
def mouse_pressed(app, event):
(row, col) = get_cell(app, event.x, event.y)
# velg denne ruten med mindre den allerede er valgt
if (app.selection == (row, col)):
app.selection = (-1, -1)
else:
app.selection = (row, col)
def redraw_all(app, canvas):
# tegn alle rutene
for row in range(app.rows):
for col in range(app.cols):
(x0, y0, x1, y1) = get_cell_bounds(app, row, col)
fill = "orange" if (app.selection == (row, col)) else "cyan"
canvas.create_rectangle(x0, y0, x1, y1, fill=fill)
canvas.create_text(app.width/2, app.height/2, text="Klikk på rutene!",
font="Arial 20 bold", fill="darkBlue")
run_app(width=400, height=300)
Eksempel: knapper
from uib_inf100_graphics import *
def app_started(app):
app.count = 0
app.buttons = [
# [x1, y1, x2, y2, "Navn på knapp", funksjon]
[30, 30, 130, 60, "Opp", increase],
[150, 30, 250, 60, "Ned", decrease]
]
def increase(app):
app.count += 1
def decrease(app):
app.count -= 1
def point_in_rectangle(x1, y1, x2, y2, x, y):
return (min(x1, x2) <= x <= max(x1, x2)
and min(y1, y2) <= y <= max(y1, y2))
def execute_button_action_if_clicked(app, button, mouse_x, mouse_y):
x1, y1, x2, y2, label, func = button
if point_in_rectangle(x1, y1, x2, y2, mouse_x, mouse_y):
func(app)
def mouse_pressed(app, event):
for button in app.buttons:
execute_button_action_if_clicked(app, button, event.x, event.y)
def redraw_all(app, canvas):
# tegn knappene
for button in app.buttons:
draw_button(canvas, button)
# tegn telleren
canvas.create_text(app.width/2, app.height*2/3, text=f"{app.count}",
font="Arial 20")
def draw_button(canvas, button):
x1, y1, x2, y2, label, func = button
canvas.create_rectangle(x1, y1, x2, y2, fill="lightgray")
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
canvas.create_text(mid_x, mid_y, text=label, fill="black")
run_app(width=280, height=140)