SDL2Demo/sdl_demo.py

429 lines
20 KiB
Python
Raw Permalink Normal View History

2024-02-21 00:03:07 +08:00
import sdl2.ext
# from math import sqrt
from time import sleep
# SDL related initializations
sdl2.ext.init()
width = 1024
height = 768
window = sdl2.ext.Window("Balls!", size=(width, height))
window_surface = window.get_surface()
window_pixels = sdl2.ext.PixelView(window_surface)
window.show()
# controller class
# technically unnecessary, but having keyboard input code directly manipulate physics is really goddamn messy, so should always be separated
# like using this as a "middle-man", thus making it possible also to manipulate controls by A.I, demo playback, etc. without extra code
class Controller:
left = 0
right = 0
up = 0
down = 0
class Ball:
# default location is 0 0
x = 0
y = 0
# default velocity is 0 0
vel_x = 0
vel_y = 0
# default colour values give a red ball, unless redefined at initialization
r = 255
g = 0
b = 0
# the controller object stores information if we are steering left, right, up, down
controller = Controller()
def __init__(self, size, x, y, sprite_surface):
# physics
self.x = x
self.y = y
self.size = size
self.radius = size / 2.0
self.bounce_x = bounce # assigned from global default value; x axis bounce-back
self.bounce_y = bounce # assigned from global default value; y axis bounce-back
self.accelerate = accelerate # assigned from global default value; rate of increasing x velocity by controlling
self.max_move_vel = (
max_move_vel # assigned from global default value; maximum velocity to apply when controlling
)
self.jump_vel = jump_vel # assigned from global default value; negative y velocity applied for jumping
self.floating = (
0 # determines whether the object is floating in air or standing on the bottom edge of the screen border
)
# graphics
# use the sprite which we got as an argument
self.sprite_surface = sprite_surface
# if we got None as argument, generate a new, red ball sphere
if self.sprite_surface == None:
self.sprite_surface = make_ball_sprite(self.size, 255, 0, 0, 0.66)
# read input from controller object and apply incremental changes to velocity
def do_control(self):
# if the input for left == 1 and vel is not yet -max_move_vel ("greater than" negative max_move_vel),
# accelerate with a negative value (toward left on x axis)
if self.controller.left and self.vel_x > -self.max_move_vel:
self.vel_x -= self.accelerate
# if the input for right == 1 and vel is less than max_move_vel, increase x vel by 1
if self.controller.right and self.vel_x < self.max_move_vel:
self.vel_x += self.accelerate
# if we pressed up and the ball is NOT floating (therefore it's on the ground), "jump" by setting y vel to -20
if self.controller.up and not self.floating:
self.vel_y = -self.jump_vel
# apply physics-caused changes to velocity, then manipulate x y position based on x y velocity
def do_physics(self):
# apply gravity to y vel, incrementally making the object fall faster downwards
if self.floating:
self.vel_y += gravity
# horizontal friction when touching floor, scale the vel down
if not self.floating:
self.vel_x *= friction
# when x velocity drops below a set threshold (stop_vel), stop it entirely to prevent a slow eternal slide
if abs(self.vel_x) < stop_vel:
self.vel_x = 0
# apply current velocities to position values
self.x += self.vel_x
self.y += self.vel_y
# test for collision against screen borders, and perform necessary actions
def do_edge_collision(self):
################# x collision #################
# left edge
if self.x - self.radius < 0:
# if we crossed over the left screen edge, move the ball right exactly by
# the difference between the ball's leftmost point and the left screen edge, so its edge touches the border
self.x -= self.x - self.radius
# reverse velocity for a "bounce" effect
if self.vel_x < 0:
self.do_bounce_x()
# same shit for right edge
elif self.x + self.radius > width - 1:
# if we crossed over the right screen edge, move the ball back enough so it's inside, its edge touching the border, again
self.x -= self.x + self.radius - width
# reverse velocity for a "bounce" effect
if self.vel_x > 0:
self.do_bounce_x()
################# y collision ####################
# top edge
if self.y - self.radius < 0:
self.y -= self.y - self.radius
if self.vel_y < 0:
self.do_bounce_y()
# bottom edge
elif self.y + self.radius > height - 1:
self.y -= self.y + self.radius - height
if self.vel_y > 0:
self.do_bounce_y()
# since we're touching the floor, we are NOT floating in air; set floating to 0
self.floating = 0
else:
# otherwise we clearly are floating in the air, high as a kite
self.floating = 1
# the below functions could also cause some bouncy squeezy animation to play
def do_bounce_x(self):
self.vel_x *= self.bounce_x
def do_bounce_y(self):
self.vel_y *= self.bounce_y
# draw function, to copy the ball's (pre-calculated, pre-drawn) image surface, or "sprite", onto the window surface
def draw(self):
# the blitsurface function copies sprite_surface to window_surface
# it's much faster than setting pixels one by one via PixelView
# the "None" could be replaced with: sdl.SDL_Rect(x, y, w, h) if we only wanted to copy part of the sprite
# such as if it's actually a sprite sheet graphic, and we're just copying one piece, representing one frame
# the SDL_Rect that __is__ there, is the destination coordinates (x, y, w, h) on the screen where we want to draw
# you could scale the sprite larger or smaller along x and y axes by inserting different values for w and h there
# instead of BlitSurface, we use BlitScaled; exact same, except Scaled will resize the source surface to the given area
sdl2.SDL_BlitScaled(
self.sprite_surface, # source surface
None, # source rectangle
window_surface, # target surface
sdl2.SDL_Rect( # target rectangle
int(self.x - self.size // 2), # target rect. start x
int(self.y - self.size // 2), # target rect. start y
self.size, # target rect. width
self.size, # target rect. height
),
)
# the function is written on multiple lines for the sake of clarity, but could be written on one line all the same;
# such as the original blitsurface version of the function, commented out below
# sdl2.SDL_BlitSurface(self.sprite_surface, None, window_surface, sdl2.SDL_Rect(int(self.x-self.radius), int(self.y-self.radius), self.size, self.size))
def draw_circle(x, y, radius, r, g, b, target_pixels, shaded):
# cast coordinates into integers, since they might be fractional numbers,
# but we need integers to know which exact pixels to reference
x = int(x)
y = int(y)
# turn a flag on to create an extra pixel if the circle radius would produce even numbered coordinates
# (e.g. if the circle is 100 x 100, it must have 4 pixels in the middle)
# % 1 gets the fractional part of any number (e.g. 10.75 gives 0.75), abs() gets the absolute value (e.g. -0.1 becomes 0.1)
# k must be 1 for even numbered, and 0 for odd numbered coordinates
if abs((radius % 1) - 0.5) < 0.5:
k = 0
else:
k = 1
# now that we don't need the fractional part anymore, we cast the radius to an integer, rounding down
# if k is 0, we inferred from the radius that the circle size is closer to an odd number
# in this case the rounding down "loses" an odd pixel along each dimension, and so we add 1 to the radius
radius = int(radius) + (1 - k)
# we need these 2ndary variables if the circle is shaded; explained below
new_r = r
new_g = g
new_b = b
# squared radius for an optimization trick; explained below
radius_squared = radius**2
# process each pixel in a squrae area; not the whole pixel area of the ball, just one quadrant of it
for y_pix in range(0, radius):
for x_pix in range(0, radius):
# get each pixel's distance from the square's centre
# distance = math.sqrt(y_pix**2 + x_pix**2)
# instead of getting the square root of the distance every time,
# we get the square of the radius once at the beginning of the function (above)
# and compare the squared values; the mathematical outcome in comparing which is greater, is the exact same! :O
# but now we have saved ourselves lots of processing power, and don't need the sqrt library!
distance_squared = y_pix**2 + x_pix**2
# if the pixel is within a radius, draw it; otherwise leave it empty
if distance_squared < radius_squared:
# if the circle is shaded, modify r,g,b values using distance/radius ratio and shaded modifier
if shaded != 0:
# shaded == 1 gives a fully shaded ball, almost black near edges
# (think like: 1 * 100% of red is reduced from red if distance/radius == 1.0)
# (think like: 1 * 50% of red is reduced from red if distance/radius == 0.5)
# shaded == 0.5 gives a half-shaded ball, which has half-brightness of original colour near the edges
# (think like: 0.5 * 100% == 50% of red is reduced from red if distance/radius == 1.0)
# (think like: 0.5 * 50% == 25% of red is reduced from red if distance/radius == 0.5)
# shaded == 0 is not shaded at all, plain colour
# please note that we have to calculate a new shaded colour for each pixel,
# while preserving the original r, g, b values; hence we need the new_r, new_g, new_b variables
new_r = r - int(shaded * r * (distance_squared / radius_squared))
new_g = g - int(shaded * g * (distance_squared / radius_squared))
new_b = b - int(shaded * b * (distance_squared / radius_squared))
# we do 4 pixels at a time, just flipping - and + for coordinate offsets from x and y
# we do the math above for one quadrant of the image, but write a mirrored pixel into all four quadrants
# since the circle image is symmetrical on x and y axes, this saves time and processing power
# you could even calculate just 1 octant and draw 8 pixels in all octants, but that would overcomplicate the code for this example
try:
target_pixels[y - y_pix - k][x - x_pix - k] = sdl2.ext.Color(new_r, new_g, new_b)
target_pixels[y - y_pix - k][x + x_pix] = sdl2.ext.Color(new_r, new_g, new_b)
target_pixels[y + y_pix][x - x_pix - k] = sdl2.ext.Color(new_r, new_g, new_b)
target_pixels[y + y_pix][x + x_pix] = sdl2.ext.Color(new_r, new_g, new_b)
except:
print("Warning: attempting to draw outside of boundaries!")
def make_ball_sprite(size, r, g, b, shaded):
# a surface is just data containing pixel graphics, like an image
# because SDL_CreateRGBSurface() returns a memory address (pointer) to a surface, instead of a surface itself...
p_sprite_surface = sdl2.SDL_CreateRGBSurface(0, size, size, 32, 0xFF000000, 0x00FF0000, 0x0000FF00, 0x000000FF)
# ...we need to create another variable from the contents (actual surface) at that memory address
sprite_surface = p_sprite_surface.contents
# in order to be able to manipulate the pixels of the image, we need this PixelView thing to "unlock" its memory
sprite_pixels = sdl2.ext.PixelView(sprite_surface)
# with all that shit done, we can finally fucking draw some shit on the image
# start coordinates are size // 2, since the x, y given to draw_circle are its middle point
draw_circle(size // 2, size // 2, size / 2, r, g, b, sprite_pixels, 0.75)
# now that we're finished manipulating the surface's pixels, return the surface to whoever called
return sprite_surface
def process_events(control):
# get_events() gets a "queue" list of all kinds of happenings from keyboard, mouse, whatever
# these things are called events, though in other contexts the word can also refer to in-game events (player died, grenade exploded...)
events = sdl2.ext.get_events()
# you can then walk through this queue of events using a for loop, and check for the ones you want
# see https://wiki.libsdl.org/SDL_EventType for a complete list
for event in events:
# function returns False to the while (running) loop, making running = False
# and thus exit the loop - and also exit the program
if event.type == sdl2.SDL_QUIT:
return False
# SDL_GetKeyboardState(None) creates a data structure that contains status of all keyboard buttons
# please keep in mind that get_events() must be called first or else there is nothing
keyboard_state = sdl2.SDL_GetKeyboardState(None)
# the control object given as argument to this function is used to store 1 or 0 for left, right, up, down
# depending on if that key element on the keyboard_state list is 1 or 0
# for a full list, see list of SDL_SCANCODE_ * in https://wiki.libsdl.org/SDL_Scancode
control.left = keyboard_state[sdl2.SDL_SCANCODE_LEFT]
control.right = keyboard_state[sdl2.SDL_SCANCODE_RIGHT]
control.up = keyboard_state[sdl2.SDL_SCANCODE_UP]
control.down = keyboard_state[sdl2.SDL_SCANCODE_DOWN]
# don't forget to return True to the main loop that calls this function, so running = True
return True
# global physics variables
gravity = 1 # rate at which vel_y increases every cycle to simulate gravity
friction = 0.8 # multiplier for scaling x velocity when colliding with bottom edge
bounce = -0.9 # multiplier for reversing velocity when an object collides and bounces off
stop_vel = 0.25 # threshold below which velocity is set to 0
accelerate = 1 # rate at which vel_x changes when controlling left/right
max_move_vel = 8 # stop applying left/right force once max vel reached
jump_vel = 20 # y_vel to be applied for jumping (as a negative value -> up)
# a quick physics lesson:
# https://i.imgur.com/9qKajZI.png
# * speed is how fast you are travelling, e.g. car travelling at speed of 20 m/s
# * velocity is speed in a given direction, e.g. a car is travelling east at a velocity of 10/ms
# velocity is usually a vector, i.e. information represented using two or three axises, so:
# a car with a north velocity (y axis) of 10 m/s and east velocity (x axis) of 10 m/s would have an actual speed of ~14.1 m/s
# that is why we use the word velocity, and seldom call anything speed
################## MAIN PROGRAM EXECUTION ##########################
# globals for testing
color = sdl2.ext.Color(255, 0, 255)
# let's create some balls because why not
# 1st ball should auto-generate its own red ball sprite because we give None as the sprite surface argument
Ball1 = Ball(100, 200, 200, None)
Ball1.vel_x = 0
Ball1.vel_y = 0
Ball1.bounce_y = 0 # disable the vertical bouncing to make this ball easier to control
# let's make a blue sprite for the 2nd ball
# we purposefully make the sprite smaller than teh ball, to see that BlitScaled will scale the sprite up
# to the target drawing area on the screen
blue_ball_sprite = make_ball_sprite(15, 100, 100, 255, 0.66)
# and the 2nd ball that uses it
Ball2 = Ball(50, 100, 200, blue_ball_sprite)
Ball2.vel_x = -50
Ball2.vel_y = -10
# Let's make a 3rd ball that uses the same sprite image too
Ball3 = Ball(100, 400, 200, blue_ball_sprite)
Ball3.vel_x = 0
Ball3.vel_y = 0
# just to prove a point, let's edit the blue ball sprite by adding a green square to it
# and see that the change shows up in both blue balls, proving they don't store unique copies
# but instead, have memory pointers to the same graphic!
sdl2.ext.fill(blue_ball_sprite, sdl2.ext.Color(0, 255, 0), (5, 5, 5, 5))
# just for the sake of completeness and rehearsal, let's also see how to add just 1 pixel, a red dot at the centre
blue_ball_sprite_pixels = sdl2.ext.PixelView(blue_ball_sprite)
blue_ball_sprite_pixels[7][7] = sdl2.ext.Color(255, 0, 0)
# could be called main(), run(), execute() or whatever
def run():
# example of a game loop, demonstrating in which order to execute things
running = True
while running == True:
######################## 1. GET INPUT & PROCESS EVENTS ####################################
# get player input into Ball1.controller and check events for quitting,
# set running to False if quit, otherwise True
# control input could also be stored to some kind of global_controller object,
# such as when you want to control a menu, or copy input from a global object
# to multiple balls and other things, to simultaneously control many things
running = process_events(Ball1.controller)
######################## 2. LOGIC PROCESSING (physics, AI, etc.) ########################
# now that we processed events and stored keyboard state into Ball1.controller,
# first alter the Ball1's velocity using its control() function
Ball1.do_control()
# then work out physics, first gravity, then how friction affects vel values,
# and then move the ball's physical location (x, y) based on the final vel values
Ball1.do_physics()
Ball2.do_physics()
Ball3.do_physics()
# since the ball has been moved now, we must check if it has collided, and do things like
# correct its position if it went through the edge, or change velocity values to "bounce" it off
Ball1.do_edge_collision()
Ball2.do_edge_collision()
Ball3.do_edge_collision()
# for countless more balls, it would make more sense for program code to look like this:
# list_of_balls = []
# list_of_balls.append(Ball(size_here, some_x, some_y, some_sprite or None))
# ...
# for ball in list_of_balls:
# ball.do_some_thing()
# ...
# for ball in list_of_balls:
# ball.do_another_thing()
# ...
# you would certainly need to do this for things like particles, bullets, etc. which there are 100s or 1000s
# because you cannot write individual variables and class instances for each of them
############ 3. GRAPHICS UPDATE (draw the screen and all objects on top of it) ############
# clear screen with black background
sdl2.ext.fill(window_surface, color, (0, 0, width, height))
# with the balls now in their final positions after all the calculations done above,
# draw the balls over the black background
Ball1.draw()
Ball2.draw()
Ball3.draw()
color2 = sdl2.ext.Color(23, 0, 42)
for i in range(0,1000,2):
sdl2.ext.draw.line(
window_surface,
color2,
(
i,
int(Ball1.vel_y),
i,
int(Ball1.vel_y+100),
),
1,
)
sdl2.ext.draw.draw_rect(
window_surface,
color2,
(
i,
int(Ball1.vel_y),
i,
int(Ball1.vel_y+100),
),
1,
)
for i in range(0,1000,2):
sdl2.ext.draw.line(
window_surface,
color2,
(
int(Ball1.vel_x),
i,
int(Ball1.vel_x+100),
i,
),
1,
)
# refresh the window
window.refresh()
sleep(1 / 60) # <- this ...
# doesn't actually mean we get 60 FPS, just that we get a 1/60 second delay,
# no matter how long the game frame took to process
# program start
run()