#!/usr/bin/env python3
# Filename: A15_air_table_objects.py
"""
Core objects for the air table physics simulation.
This module defines the physical objects that can exist in the simulation:
Classes:
Wall: Static boundary objects with collision detection
Puck: Dynamic objects with physics properties (mass, velocity, etc.)
Spring: Elastic connections between pucks with customizable properties
RotatingTube: Base class for rotatable attachments (Jet/Gun)
Jet: Propulsion system that can be attached to pucks
Gun: Weapon system that can be mounted on pucks
Walls and Puck supports Box2D integration for advanced physics simulation when enabled.
"""
import math
import random
from typing import Optional
import pygame
from pygame.color import THECOLORS
from Box2D import b2Vec2
# Import the vector class from a local module
from A09_vec2d import Vec2D
# Global variables shared across scripts
import A15_globals as g
class Wall:
def __init__(self, pos_2d_m, half_width_m, half_height_m, angle_radians=0.0,
color=THECOLORS["gray"], border_px=3, fence=False):
self.pos_2d_m = pos_2d_m
self.half_width_m = half_width_m
self.half_height_m = half_height_m
self.angle_radians = angle_radians
self.color = color
self.border_px = border_px
self.fence = fence
self.b2d_body = self.create_Box2d_Wall()
g.air_table.walls.append(self)
def create_Box2d_Wall(self):
# Create a static body
static_body = g.air_table.b2d_world.CreateStaticBody(position=b2Vec2(self.pos_2d_m.tuple()), angle=self.angle_radians )
# And add a box fixture onto it.
static_body.CreatePolygonFixture(box=(self.half_width_m, self.half_height_m))
return static_body
def delete(self):
# Remove the wall from the world in box2d.
g.air_table.b2d_world.DestroyBody(self.b2d_body)
g.air_table.walls.remove( self)
def draw(self):
fixture_shape = self.b2d_body.fixtures[0].shape
vertices_screen_2d_px = []
for vertex_object_2d_m in fixture_shape.vertices:
vertex_world_2d_m = self.b2d_body.transform * vertex_object_2d_m # Overload operation
vertex_screen_2d_px = g.env.ConvertWorldToScreen( Vec2D(vertex_world_2d_m.x, vertex_world_2d_m.y)) # This returns a tuple
vertices_screen_2d_px.append( vertex_screen_2d_px) # Append to the list.
pygame.draw.polygon(g.game_window.surface, self.color, vertices_screen_2d_px, g.env.zoomLineThickness(self.border_px))
class Puck:
def __init__(self, pos_2d_m, radius_m, density_kgpm2, vel_2d_mps=Vec2D(0.0,0.0),
angle_r=math.pi/2, angularVelocity_rps=0, showSpoke=True,
c_drag=0.0, coef_rest=0.85, CR_fixed=False,
hit_limit=50.0, show_health=False, age_limit_s=3.0,
color=THECOLORS["gray"], client_name=None, bullet=False, pin=False, border_px=3,
rect_fixture=False, hw_ratio=1.0, groupIndex=0, awake=True,
friction=0.2, friction_fixed=False, c_angularDrag=0.0):
self.radius_m = radius_m
self.diameter_m = 2 * radius_m
self.radius_px = round(g.env.px_from_m(self.radius_m))
self.density_kgpm2 = density_kgpm2 # mass per unit area
self.mass_kg = self.density_kgpm2 * math.pi * self.radius_m ** 2
self.c_drag = c_drag
self.coef_rest = coef_rest
self.coef_rest_atBirth = coef_rest
self.CR_fixed = CR_fixed
# For a Box2d puck.
self.width_m = None
self.height_m = None
self.friction = friction
self.friction_atBirth = friction
self.friction_fixed = friction_fixed
self.pos_2d_m = pos_2d_m
self.vel_2d_mps = vel_2d_mps
# Box2d puck
self.showSpoke = showSpoke
self.angle_r = angle_r
self.angularVelocity_rps = angularVelocity_rps
self.groupIndex = groupIndex
self.SprDamp_force_2d_N = Vec2D(0.0,0.0)
self.jet_force_2d_N = Vec2D(0.0,0.0)
self.cursorString_spring_force_2d_N = Vec2D(0.0,0.0)
self.cursorString_puckDrag_force_2d_N = Vec2D(0.0,0.0)
self.puckDrag_force_2d_N = Vec2D(0.0,0.0)
self.impulse_2d_Ns = Vec2D(0.0,0.0)
self.selected = False
# For a Box2d puck.
self.rotation_speed = 0.0
self.c_angularDrag = c_angularDrag
self.cursorString_torque_force_Nm = 0
# Non-center of mass (COM). #b2d
# This is a list of dictionaries: each dictionary contains a force and a body location
self.nonCOM_N = []
self.color = color
self.border_thickness_px = border_px
self.client_name = client_name
self.jet: Optional[Jet] = None
self.gun: Optional[Gun] = None
self.hit = False
self.hitflash_duration_timer_s = 0.0
# Make the hit flash persist for this number of seconds:
self.hitflash_duration_timer_limit_s = 0.05
self.show_health = show_health
# bullet nature
self.bullet = bullet
self.birth_time_s = g.air_table.time_s
self.age_limit_s = age_limit_s
# Keep track of health.
self.bullet_hit_count = 0
self.bullet_hit_limit = hit_limit
# For a Box2d puck.
self.hw_ratio = hw_ratio # height to width ratio
self.rect_fixture = rect_fixture
self.awake = awake
self.b2d_body = None
self.pin = pin
# Add puck to the lists of pucks, controlled pucks, and target pucks.
if not pin:
if (g.air_table.engine == 'box2d'):
self.b2d_body = self.create_Box2d_Puck()
g.air_table.puck_dictionary[self.b2d_body] = self
g.air_table.pucks.append(self)
if not self.bullet:
g.air_table.target_pucks.append(self)
if self.client_name:
g.air_table.controlled_pucks.append(self)
# If you print an object instance...
def __str__(self):
return f"puck: x is {self.pos_2d_m.x}, y is {self.pos_2d_m.y}"
# Box2d
def create_Box2d_Puck(self):
# Create a dynamic body
dynamic_body = g.air_table.b2d_world.CreateDynamicBody(
position=b2Vec2(self.pos_2d_m.tuple()),
angle=self.angle_r, angularVelocity=self.angularVelocity_rps,
linearVelocity=b2Vec2(self.vel_2d_mps.tuple()),
awake=self.awake
)
if self.rect_fixture:
# And add a box fixture onto it.
half_width_m = self.radius_m
half_height_m = half_width_m * self.hw_ratio
self.width_m = half_width_m * 2.0
self.height_m = half_height_m * 2.0
dynamic_body.CreatePolygonFixture(
box=(half_width_m, half_height_m),
density=self.density_kgpm2,
friction=self.friction_atBirth, restitution=self.coef_rest_atBirth
)
else:
# And add a circular fixture onto it.
dynamic_body.CreateCircleFixture(
radius=self.radius_m,
density=self.density_kgpm2,
friction=self.friction_atBirth, restitution=self.coef_rest_atBirth
)
dynamic_body.fixtures[0].filterData.groupIndex = self.groupIndex
# Set the mass attribute based on what box2d calculates.
self.mass_kg = dynamic_body.mass
# fluid drag inside Box2D
dynamic_body.linearDamping = self.c_drag
dynamic_body.angularDamping = self.c_angularDrag
dynamic_body.bullet = self.bullet
return dynamic_body
# Box2d
def get_Box2d_XandV(self):
# Position
box2d_pos_2d_m = self.b2d_body.GetWorldPoint(b2Vec2(0,0))
self.pos_2d_m = Vec2D( box2d_pos_2d_m.x, box2d_pos_2d_m.y)
# Velocity
box2d_vel_2d_m = self.b2d_body.linearVelocity
self.vel_2d_mps = Vec2D( box2d_vel_2d_m.x, box2d_vel_2d_m.y)
# Rotational speed.
self.rotation_speed = self.b2d_body.angularVelocity
def set_pos_and_vel(self, pos_2d_m, vel_2d_m=Vec2D(0,0)):
# Update our vectors
self.pos_2d_m = pos_2d_m
self.vel_2d_mps = vel_2d_m
if (g.air_table.engine == 'box2d'):
# Update Box2D body
self.b2d_body.position = b2Vec2(pos_2d_m.x, pos_2d_m.y)
self.b2d_body.linearVelocity = b2Vec2(vel_2d_m.x, vel_2d_m.y)
def delete(self):
if (g.air_table.engine == 'box2d'):
# Remove the puck from the dictionary.
del g.air_table.puck_dictionary[self.b2d_body]
# Remove the puck from the world in box2d.
g.air_table.b2d_world.DestroyBody(self.b2d_body)
if (not self.bullet):
# Delete any springs that connect this puck to other pucks.
for spring in g.air_table.springs[:]:
if (spring.p1 == self) or (spring.p2 == self):
g.air_table.springs.remove( spring)
# If a client has selected this puck (a cursor string connected),
# unselect it so the cursor string won't continue to be drawn.
for client_name in g.env.clients:
client = g.env.clients[client_name]
if client.selected_puck == self:
client.selected_puck = None
# Remove the puck from special lists.
if self in g.air_table.controlled_pucks:
g.air_table.controlled_pucks.remove(self)
if self in g.air_table.target_pucks:
g.air_table.target_pucks.remove(self)
g.air_table.pucks.remove(self)
def calc_regularDragForce(self):
self.puckDrag_force_2d_N = self.vel_2d_mps * -1 * self.c_drag
def draw(self, tempColor=None):
# Convert x,y to pixel screen location and then draw.
self.pos_2d_px = g.env.ConvertWorldToScreen( self.pos_2d_m)
# Update based on zoom factor in px_from_m.
self.radius_px = round(g.env.px_from_m( self.radius_m))
if (self.radius_px < 2):
self.radius_px = 2
# Just after a hit, fill the whole circle with RED (i.e., thickness = 0).
if self.hit:
puck_border_thickness = 0
puck_color = THECOLORS["red"]
self.hitflash_duration_timer_s += g.env.dt_render_limit_s
if self.hitflash_duration_timer_s > self.hitflash_duration_timer_limit_s:
self.hit = False
else:
puck_border_thickness = self.border_thickness_px
if (tempColor != None):
puck_color = tempColor
else:
puck_color = self.color
if self.rect_fixture:
# Box2d
fixture_shape = self.b2d_body.fixtures[0].shape
vertices_screen_2d_px = []
for vertex_object_2d_m in fixture_shape.vertices:
vertex_world_2d_m = self.b2d_body.transform * vertex_object_2d_m # Overload operation
vertex_screen_2d_px = g.env.ConvertWorldToScreen( Vec2D(vertex_world_2d_m.x, vertex_world_2d_m.y)) # This returns a tuple
vertices_screen_2d_px.append( vertex_screen_2d_px) # Append to the list.
pygame.draw.polygon(g.game_window.surface, puck_color, vertices_screen_2d_px, g.env.zoomLineThickness(puck_border_thickness))
else:
# Draw main puck body.
pygame.draw.circle( g.game_window.surface, puck_color, self.pos_2d_px, self.radius_px, g.env.zoomLineThickness(puck_border_thickness))
if (g.air_table.engine == 'box2d' and not self.pin):
# If it's not a bullet and not a rectangle, draw a spoke to indicate rotational orientation.
if ((self.bullet == False) and (self.rect_fixture==False) and self.showSpoke):
# Shorten the spoke by a fraction of the thickness so that its end
# (and the blocky rendering) is hidden in the border.
reduction_m = g.env.px_to_m * self.border_thickness_px * 0.50
# Position the outer-edge point right from the center (r_m, 0), so
# spoke will look like it's at zero angle.
point_on_radius_b2d_m = self.b2d_body.GetWorldPoint( b2Vec2(self.radius_m - reduction_m, 0.0))
point_on_radius_2d_m = Vec2D( point_on_radius_b2d_m.x, point_on_radius_b2d_m.y)
point_on_radius_2d_px = g.env.ConvertWorldToScreen( point_on_radius_2d_m)
point_at_center_b2d_m = self.b2d_body.GetWorldPoint( b2Vec2(0.0, 0.0))
point_at_center_2d_m = Vec2D( point_at_center_b2d_m.x, point_at_center_b2d_m.y)
point_at_center_2d_px = g.env.ConvertWorldToScreen( point_at_center_2d_m)
pygame.draw.line(g.game_window.surface, puck_color, point_on_radius_2d_px, point_at_center_2d_px, g.env.zoomLineThickness(puck_border_thickness))
# Round the end of the spoke that is at the center of the puck.
#pygame.draw.circle( g.game_window.surface, puck_color, self.pos_2d_px, 0.7 *g.env.zoomLineThickness(puck_border_thickness), 0)
# Draw life (poor health) indicator circle.
if (not self.bullet and self.show_health):
spent_fraction = float(self.bullet_hit_count) / float(self.bullet_hit_limit)
if self.rect_fixture:
life_radius_px = spent_fraction * self.radius_px * self.hw_ratio
else:
life_radius_px = spent_fraction * self.radius_px
if (life_radius_px < 2.0):
life_radius_px = 2.0
pygame.draw.circle(g.game_window.surface, THECOLORS["red"], self.pos_2d_px, life_radius_px, g.env.zoomLineThickness(2))
class RotatingTube:
def __init__(self, puck, sf_abs=False):
# Associate the tube with the puck.
self.puck = puck
self.color = g.env.clients[self.puck.client_name].cursor_color
# Degrees of rotation per second.
self.rotation_rate_dps = 360.0
# Scaling factors to manage the aspect ratio of the tube.
if sf_abs: # Absolute
self.sf_x = 0.15
self.sf_y = 0.50
else: # Relative
self.sf_x = 0.15 * (self.puck.radius_m/0.45)
self.sf_y = 0.50 * (self.puck.radius_m/0.45)
# Notice the counter-clockwise drawing pattern. Four vertices for a rectangle.
# Each vertex is represented by a vector.
self.tube_vertices_2d_m = [Vec2D(-0.50 * self.sf_x, 0.00 * self.sf_y),
Vec2D( 0.50 * self.sf_x, 0.00 * self.sf_y),
Vec2D( 0.50 * self.sf_x, 1.00 * self.sf_y),
Vec2D(-0.50 * self.sf_x, 1.00 * self.sf_y)]
# Define a normal (1 meter) pointing vector to keep track of the direction of the jet.
self.direction_2d_m: Vec2D = Vec2D(0.0, 1.0)
def rotate_vertices(self, vertices_2d_m, angle_deg):
for vertex_2d_m in vertices_2d_m:
vertex_2d_m.rotated( angle_deg, sameVector=True)
def rotate_everything(self, angle_deg):
# Rotate the pointer.
self.direction_2d_m.rotated( angle_deg, sameVector=True)
# Rotate the tube.
self.rotate_vertices( self.tube_vertices_2d_m, angle_deg)
def convert_from_world_to_screen(self, vertices_2d_m, base_point_2d_m):
vertices_2d_px = []
for vertex_2d_m in vertices_2d_m:
# Calculate absolute position of this vertex.
vertices_2d_px.append( g.env.ConvertWorldToScreen(vertex_2d_m + base_point_2d_m))
return vertices_2d_px
def draw_tube(self, line_thickness=3):
# Draw the tube on the game-window surface. Establish the base_point as the center
# of the puck.
pygame.draw.polygon(g.game_window.surface, self.color,
self.convert_from_world_to_screen(self.tube_vertices_2d_m, self.puck.pos_2d_m), g.env.zoomLineThickness(line_thickness))
class Jet(RotatingTube):
def __init__(self, puck, sf_abs=True):
# Associate the jet with the puck (referenced in the RotatingTube class).
super().__init__(puck, sf_abs=sf_abs)
# Degrees of rotation per second.
self.rotation_rate_dps = 360.0
self.color = THECOLORS["yellow4"]
# The jet flame (triangle)
self.flame_vertices_2d_m =[Vec2D(-0.50 * self.sf_x, 1.02 * self.sf_y),
Vec2D( 0.50 * self.sf_x, 1.02 * self.sf_y),
Vec2D(-0.00 * self.sf_x, 1.80 * self.sf_y)]
# The nose (triangle)
self.nose_vertices_2d_m =[Vec2D(-0.50 * self.sf_x, -1.02 * self.sf_y),
Vec2D( 0.50 * self.sf_x, -1.02 * self.sf_y),
Vec2D(-0.00 * self.sf_x, -1.40 * self.sf_y)]
# Scaler magnitude of jet force.
self.jet_force_N = 1.3 * self.puck.mass_kg * abs(g.air_table.gON_2d_mps2.y)
# Point everything down for starters.
self.rotate_everything( 180)
self.client = g.env.clients[self.puck.client_name]
def turn_jet_forces_onoff(self):
if (self.client.key_w == "D"):
# Force on puck is in the opposite direction of the jet tube.
self.puck.jet_force_2d_N = self.direction_2d_m * (-1) * self.jet_force_N
else:
self.puck.jet_force_2d_N = self.direction_2d_m * 0.0
def client_rotation_control(self):
if (self.client.key_a == "D"):
self.rotate_everything( +1 * self.rotation_rate_dps * g.env.dt_render_limit_s)
if (self.client.key_d == "D"):
self.rotate_everything( -1 * self.rotation_rate_dps * g.env.dt_render_limit_s)
if (self.client.key_s == "D"):
# Rotate jet tube to be in the same direction as the motion of the puck.
puck_velocity_angle = self.puck.vel_2d_mps.get_angle()
current_jet_angle = self.direction_2d_m.get_angle()
self.rotate_everything(puck_velocity_angle - current_jet_angle)
# Reset this so it doesn't keep flipping. Just want it to flip the direction
# once but not keep flipping. This first line is enough to keep the local
# client from flipping again because the local keyboard doesn't keep sending
# the "D" event if the key is held down.
self.client.key_s = "U"
# This second one is also needed for the network clients because they keep
# sending the "D" until they release the key.
self.client.key_s_onoff = "OFF"
def rotate_everything(self, angle_deg):
# Rotate the pointer.
self.direction_2d_m.rotated( angle_deg, sameVector=True)
# Rotate the tube.
self.rotate_vertices( self.tube_vertices_2d_m, angle_deg)
# Rotate the nose.
self.rotate_vertices( self.nose_vertices_2d_m, angle_deg)
# Rotate the flame.
self.rotate_vertices( self.flame_vertices_2d_m, angle_deg)
def draw(self):
if self.client.drone: return
# Draw the jet tube.
self.draw_tube(line_thickness=0)
# Draw a little nose cone on the other side of the puck from the jet. This is a
# visual aid to help the player see the direction the puck will go when the jet is
# on.
pygame.draw.polygon(g.game_window.surface, THECOLORS["yellow1"],
self.convert_from_world_to_screen(self.nose_vertices_2d_m, self.puck.pos_2d_m), 0)
# Draw the red flame.
if (self.client.key_w == "D"):
pygame.draw.polygon(g.game_window.surface, THECOLORS["red"],
self.convert_from_world_to_screen(self.flame_vertices_2d_m, self.puck.pos_2d_m), 0)
class Gun( RotatingTube):
def __init__(self, puck, sf_abs=True, bullet_age_limit_s=3.0):
# Associate the gun with the puck (referenced in the RotatingTube class).
super().__init__(puck, sf_abs=sf_abs)
# Degrees of rotation per second.
self.rotation_rate_dps = 180.0
self.color = g.env.clients[self.puck.client_name].cursor_color
# Set a negative group index for bullet stream (inhibit collisions with itself)
if self.puck.client_name == "local":
self.groupIndex = -100
else:
self.groupIndex = -int(self.puck.client_name[1:])
# Run this method of the RotationTube class to set the initial angle of each new gun.
self.rotate_everything( 45)
self.bullet_speed_mps = 5.0
self.fire_time_s = g.air_table.time_s
self.firing_delay_s = 0.1
self.bullet_count = 0
self.bullet_count_limit = 10
self.bullet_age_limit_s = bullet_age_limit_s
self.gun_recharge_wait_s = 2.5
self.gun_recharge_start_time_s = g.air_table.time_s
self.gun_recharging = False
self.shield = False
self.shield_hit = False
self.shield_hit_duration_s = 0.0
# Make the hit remove the shield for this number of seconds:
self.shield_hit_duration_limit_s = 0.05
self.shield_hit_count = 0
self.shield_hit_count_limit = 20
self.shield_recharging = False
self.shield_recharge_wait_s = 4.0
self.shield_recharge_start_time_s = g.air_table.time_s
self.shield_thickness = 5
self.targetPuck = None
self.client = g.env.clients[self.puck.client_name]
def client_rotation_control(self):
if (self.client.key_j == "D"):
self.rotate_everything( +self.rotation_rate_dps * g.env.dt_render_limit_s)
if (self.client.key_l == "D"):
self.rotate_everything( -self.rotation_rate_dps * g.env.dt_render_limit_s)
if (self.client.key_k == "D"):
# Rotate jet tube to be in the same direction as the motion of the puck.
puck_velocity_angle = self.puck.vel_2d_mps.get_angle()
current_gun_angle = self.direction_2d_m.get_angle()
self.rotate_everything(puck_velocity_angle - current_gun_angle)
# Reset this so it doesn't keep flipping. Just want it to flip the direction
# once but not keep flipping. This first line is enough to keep the local
# client from flipping again because the local keyboard doesn't keep sending
# the "D" event if the key is held down.
self.client.key_k = "U"
# This second one is also needed for the network clients because they keep
# sending the "D" until they release the key.
self.client.key_k_onoff = "OFF"
def drone_rotation_control(self):
if self.targetPuck:
vectorToTarget = self.targetPuck.pos_2d_m - self.puck.pos_2d_m
angle_change = vectorToTarget.get_angle() - self.direction_2d_m.get_angle()
self.rotate_everything( angle_change + 1.0)
def findNewTarget(self):
puck_indexes = list( range( len( g.air_table.target_pucks)))
# Shuffle them.
random.shuffle( puck_indexes)
for puck_index in puck_indexes:
puck = g.air_table.target_pucks[ puck_index]
# Other than itself, pick a new target.
if (puck != self.puck) and (puck != self.targetPuck):
self.targetPuck = puck
break
def control_firing(self):
droneShooting = self.client.drone and (len(g.air_table.target_pucks) > 1)
# Fire only if the shield is off.
if ((self.client.key_i == "D") and (not self.shield)) or droneShooting:
# Fire the gun.
if ((g.air_table.time_s - self.fire_time_s) > self.firing_delay_s) and (not self.gun_recharging):
self.fire_gun()
self.bullet_count += 1
# Timestamp the firing event.
self.fire_time_s = g.air_table.time_s
# Check to see if gun bullet count indicates the need to start recharging.
if (self.bullet_count > self.bullet_count_limit):
self.gun_recharge_start_time_s = g.air_table.time_s
self.gun_recharging = True
self.bullet_count = 0
# At the beginning of the charging period, find a new target. This gives a
# human player an indication of what the drone is targeting. And since this is
# at the beginning of the gun charging period, it gives the player some time
# for evasive maneuvers.
if self.client.drone:
self.findNewTarget()
# If recharged.
if (self.gun_recharging and (g.air_table.time_s - self.gun_recharge_start_time_s) > self.gun_recharge_wait_s):
self.gun_recharging = False
# If the puck the drone is aiming at has been destroyed, find a new target
# before starting to shoot.
if self.client.drone and not (self.targetPuck in g.air_table.target_pucks):
self.findNewTarget()
def fire_gun(self):
bullet_radius_m = 0.05
# Set the initial position of the bullet so that it clears (doesn't collide with)
# the host puck.
initial_position_2d_m = (self.puck.pos_2d_m +
(self.direction_2d_m * (1.1 * self.puck.radius_m + 1.1 * bullet_radius_m)) )
# Relative velocity of the bullet: the bullet velocity as seen from the host puck.
# This is the speed of the bullet relative to the motion of the host puck (host
# velocity BEFORE the firing of the bullet).
bullet_relative_vel_2d_mps = self.direction_2d_m * self.bullet_speed_mps
# Absolute velocity of the bullet
bullet_absolute_vel_2d_mps = self.puck.vel_2d_mps + bullet_relative_vel_2d_mps
temp_bullet = Puck(initial_position_2d_m, bullet_radius_m, 0.3, vel_2d_mps=bullet_absolute_vel_2d_mps,
bullet=True, age_limit_s=self.bullet_age_limit_s, groupIndex=self.groupIndex)
temp_bullet.color = self.client.cursor_color
temp_bullet.client_name = self.puck.client_name
# Calculate the recoil impulse from firing the gun (opposite the direction of the bullet).
self.puck.impulse_2d_Ns = bullet_relative_vel_2d_mps * temp_bullet.mass_kg * (-1)
def control_shield(self):
if (self.client.key_space == "D") and (not self.shield_recharging):
self.shield = True
else:
self.shield = False
# Check to see if the shield hit count indicates the need to start recharging.
if self.shield_hit_count > self.shield_hit_count_limit:
self.shield_recharge_start_time_s = g.air_table.time_s
self.shield = False
self.shield_recharging = True
self.shield_hit_count = 0
else:
self.shield_thickness = g.env.zoomLineThickness(5 * (1 - self.shield_hit_count/self.shield_hit_count_limit), noFill=True)
# If recharged.
if (self.shield_recharging and (g.air_table.time_s - self.shield_recharge_start_time_s) > self.shield_recharge_wait_s):
self.shield_recharging = False
def draw(self):
# Draw the gun tube.
if (self.gun_recharging):
line_thickness = 3
else:
# Fill in the gun tube.
line_thickness = 0
# Draw the jet tube.
self.draw_tube( line_thickness)
def draw_shield(self):
if (self.shield):
if self.shield_hit:
# Don't draw the shield for a moment after the hit. This visualizes the shield hit.
self.shield_hit_duration_s += g.env.dt_render_limit_s
if (self.shield_hit_duration_s > self.shield_hit_duration_limit_s):
self.shield_hit = False
else:
# Display the shield 5px outside of the puck.
shield_radius_px = self.puck.radius_px + round(5 * g.env.viewZoom)
pygame.draw.circle(g.game_window.surface, self.color,
self.puck.pos_2d_px, shield_radius_px, self.shield_thickness)
class Spring:
def __init__(self, p1, p2, length_m=3.0, strength_Npm=0.5, pin_radius_m=0.05,
color=THECOLORS["dodgerblue"], width_m=0.025, c_damp=0.5, c_drag=0.0):
# Optionally this spring can have one end pinned to a vector point. Do this by
# passing in p2 as a vector.
if (p2.__class__.__name__ == 'Vec2D'):
# Create a point puck at the pinning location. The location of this point puck
# will never change because it is not in the pucks list that is processed by
# the physics engine.
p2 = Puck( p2, pin_radius_m, 1.0, pin=True, border_px=0, color=THECOLORS['white'])
length_m = 0.0
self.p1 = p1
self.p2 = p2
self.p1p2_separation_2d_m = Vec2D(0,0)
self.p1p2_separation_m = 0
self.p1p2_normalized_2d = Vec2D(0,0)
self.length_m = length_m
self.strength_Npm = strength_Npm
self.damper_Ns2pm2 = c_damp
self.unstretched_width_m = width_m # 0.05
self.c_drag = c_drag
self.spring_vertices_2d_m = []
self.spring_vertices_2d_px = []
self.color = color
self.draw_as_line = False
# Automatically add this spring to the air_table springs list
g.air_table.springs.append(self)
def calc_spring_forces_on_pucks(self):
self.p1p2_separation_2d_m = self.p1.pos_2d_m - self.p2.pos_2d_m
self.p1p2_separation_m = self.p1p2_separation_2d_m.length()
# The pinned case needs to be able to handle the zero length spring. The
# separation distance will be zero when the pinned spring is at rest.
# This will cause a divide by zero error if not handled here.
if ((self.p1p2_separation_m == 0.0) and (self.length_m == 0.0)):
spring_force_on_1_2d_N = Vec2D(0.0,0.0)
else:
self.p1p2_normalized_2d = self.p1p2_separation_2d_m / self.p1p2_separation_m
# Spring force: acts along the separation vector and is proportional to the separation distance.
spring_force_on_1_2d_N = self.p1p2_normalized_2d * (self.length_m - self.p1p2_separation_m) * self.strength_Npm
# Damper force: acts along the separation vector and is proportional to the relative speed.
v_relative_2d_mps = self.p1.vel_2d_mps - self.p2.vel_2d_mps
v_relative_alongNormal_2d_mps = v_relative_2d_mps.projection_onto(self.p1p2_separation_2d_m)
damper_force_on_1_N = v_relative_alongNormal_2d_mps * self.damper_Ns2pm2
# Net force by both spring and damper
SprDamp_force_2d_N = spring_force_on_1_2d_N - damper_force_on_1_N
# This force acts in opposite directions for each of the two pucks. Notice the
# "+=" here, this is an aggregate across all the springs. This aggregate MUST be
# reset (zeroed) after the movements are calculated. So by the time you've looped
# through all the springs, you get the NET force, on each puck, applied by all the
# individual springs.
self.p1.SprDamp_force_2d_N += SprDamp_force_2d_N * (+1)
self.p2.SprDamp_force_2d_N += SprDamp_force_2d_N * (-1)
# Add in some drag forces if a non-zero drag coef is specified. These are based on the
# velocity of the pucks (not relative speed as is the case above for damper forces).
self.p1.SprDamp_force_2d_N += self.p1.vel_2d_mps * (-1) * self.c_drag
self.p2.SprDamp_force_2d_N += self.p2.vel_2d_mps * (-1) * self.c_drag
def width_to_draw_m(self):
width_m = self.unstretched_width_m * (1 + 0.30 * (self.length_m - self.p1p2_separation_m))
if width_m < (0.05 * self.unstretched_width_m):
self.draw_as_line = True
width_m = 0.0
else:
self.draw_as_line = False
return width_m
def draw(self):
# Change the width to indicate the stretch or compression in the spring. Note,
# it's good to do this outside of the main calc loop (using the rendering timer).
# No need to do all this each time step.
width_m = self.width_to_draw_m()
# Calculate the four corners of the spring rectangle.
p1p2_perpendicular_2d = self.p1p2_normalized_2d.rotate90()
self.spring_vertices_2d_m = []
self.spring_vertices_2d_m.append(self.p1.pos_2d_m + (p1p2_perpendicular_2d * width_m))
self.spring_vertices_2d_m.append(self.p1.pos_2d_m - (p1p2_perpendicular_2d * width_m))
self.spring_vertices_2d_m.append(self.p2.pos_2d_m - (p1p2_perpendicular_2d * width_m))
self.spring_vertices_2d_m.append(self.p2.pos_2d_m + (p1p2_perpendicular_2d * width_m))
# Transform from world to screen.
self.spring_vertices_2d_px = []
for vertice_2d_m in self.spring_vertices_2d_m:
self.spring_vertices_2d_px.append( g.env.ConvertWorldToScreen( vertice_2d_m))
# Draw the spring
if self.draw_as_line == True:
pygame.draw.aaline(g.game_window.surface, self.color, g.env.ConvertWorldToScreen(self.p1.pos_2d_m),
g.env.ConvertWorldToScreen(self.p2.pos_2d_m))
else:
pygame.draw.polygon(g.game_window.surface, self.color, self.spring_vertices_2d_px)
if self.p2.pin: self.p2.draw()