Implement colliders
This commit is contained in:
parent
d5cf0528f8
commit
5d11565a4d
12 changed files with 177 additions and 40 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,3 +12,4 @@ target/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
lib/
|
lib/
|
||||||
|
*.vscode/
|
||||||
|
|
@ -39,6 +39,7 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData):
|
||||||
match p_state.op_code:
|
match p_state.op_code:
|
||||||
2:
|
2:
|
||||||
var data = JSON.parse_string(p_state.data)
|
var data = JSON.parse_string(p_state.data)
|
||||||
|
print(data)
|
||||||
|
|
||||||
# Set player position given server bounds-checking
|
# Set player position given server bounds-checking
|
||||||
if data["forcePlayerPos"]:
|
if data["forcePlayerPos"]:
|
||||||
|
|
@ -52,6 +53,7 @@ func _on_match_state(p_state : NakamaRTAPI.MatchData):
|
||||||
int(b["tick"]),
|
int(b["tick"]),
|
||||||
b["x"],
|
b["x"],
|
||||||
b["y"],
|
b["y"],
|
||||||
|
b["radius"],
|
||||||
b["vel_x"],
|
b["vel_x"],
|
||||||
b["vel_y"])
|
b["vel_y"])
|
||||||
bullet.texture = load("res://test-bullet.png")
|
bullet.texture = load("res://test-bullet.png")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ var velocity := Vector2.ZERO
|
||||||
|
|
||||||
func get_input():
|
func get_input():
|
||||||
if Input.is_action_pressed("Slow Mode"):
|
if Input.is_action_pressed("Slow Mode"):
|
||||||
speed = 40
|
speed = 30
|
||||||
else:
|
else:
|
||||||
speed = 80
|
speed = 80
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
use shared::bullet::Bullet;
|
use shared::bullet::Bullet;
|
||||||
|
use shared::collision::Circle;
|
||||||
|
|
||||||
|
// Bullet routines
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "C" fn new_bullet(
|
pub extern "C" fn new_bullet(
|
||||||
class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, param_x: f64, param_y: f64
|
class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, radius: f64, param_x: f64, param_y: f64
|
||||||
) -> *mut Bullet {
|
) -> *mut Bullet {
|
||||||
let bullet = Bullet::new(
|
let bullet = Bullet::new(
|
||||||
class,
|
class,
|
||||||
spawn_time,
|
spawn_time,
|
||||||
spawn_x,
|
spawn_x,
|
||||||
spawn_y,
|
spawn_y,
|
||||||
|
radius,
|
||||||
[param_x, param_y],
|
[param_x, param_y],
|
||||||
);
|
);
|
||||||
Box::into_raw(Box::new(bullet))
|
Box::into_raw(Box::new(bullet))
|
||||||
|
|
@ -42,3 +45,39 @@ pub extern "C" fn destroy_bullet(bullet: *mut Bullet) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn bullet_collides_with(tick: i64, bullet: *mut Bullet, c: *const Circle) -> bool {
|
||||||
|
if bullet.is_null() || c.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe { (*bullet).collides_with(tick, &*c) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Collision
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn new_circle(x: f64, y: f64, radius: f64) -> *mut Circle {
|
||||||
|
let circle = Circle::new(x, y, radius);
|
||||||
|
Box::into_raw(Box::new(circle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn destroy_circle(circle: *mut Circle) {
|
||||||
|
if !circle.is_null() {
|
||||||
|
unsafe {
|
||||||
|
drop(Box::from_raw(circle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn circle_collides_with(c1: *const Circle, c2: *const Circle) -> bool {
|
||||||
|
if c1.is_null() || c2.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe { (*c1).collides_with(&*c2) }
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
mod ffi;
|
mod ffi;
|
||||||
|
|
||||||
pub use shared::bullet::Bullet;
|
pub use shared::bullet::Bullet;
|
||||||
|
pub use shared::collision::Circle;
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use godot::prelude::*;
|
use godot::prelude::*;
|
||||||
use shared::bullet::Bullet;
|
use shared::bullet::Bullet;
|
||||||
use godot::classes::{ Sprite2D, ISprite2D };
|
use godot::classes::{ Sprite2D, ISprite2D };
|
||||||
|
use crate::collision::DanmakuCircle;
|
||||||
|
|
||||||
#[derive(GodotClass)]
|
#[derive(GodotClass)]
|
||||||
#[class(base=Sprite2D)]
|
#[class(base=Sprite2D)]
|
||||||
struct DanmakuBullet {
|
struct DanmakuBullet {
|
||||||
bullet_state: Bullet,
|
bullet_state: Bullet,
|
||||||
|
|
||||||
base: Base<Sprite2D>,
|
base: Base<Sprite2D>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ struct DanmakuBullet {
|
||||||
impl ISprite2D for DanmakuBullet {
|
impl ISprite2D for DanmakuBullet {
|
||||||
fn init(base: Base<Sprite2D>) -> Self {
|
fn init(base: Base<Sprite2D>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bullet_state: Bullet::new(0, 0, 0.0, 0.0, [0.0, 0.0]),
|
bullet_state: Bullet::new(0, 0, 0.0, 0.0, 0.0, [0.0, 0.0]),
|
||||||
base,
|
base,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -23,8 +23,8 @@ impl ISprite2D for DanmakuBullet {
|
||||||
#[godot_api]
|
#[godot_api]
|
||||||
impl DanmakuBullet {
|
impl DanmakuBullet {
|
||||||
#[func]
|
#[func]
|
||||||
fn setup_bullet(&mut self, class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, param_x: f64, param_y: f64) {
|
fn setup_bullet(&mut self, class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, radius: f64, param_x: f64, param_y: f64) {
|
||||||
self.bullet_state = Bullet::new(class, spawn_time, spawn_x, spawn_y, [param_x, param_y]);
|
self.bullet_state = Bullet::new(class, spawn_time, spawn_x, spawn_y, radius, [param_x, param_y]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[func]
|
#[func]
|
||||||
|
|
@ -38,4 +38,8 @@ impl DanmakuBullet {
|
||||||
self.bullet_state.beyond_kill_boundary(tick)
|
self.bullet_state.beyond_kill_boundary(tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn collides_with_circle(&self, tick: i64, circle: Gd<DanmakuCircle>) -> bool {
|
||||||
|
self.bullet_state.collides_with(tick, &circle.bind().circle_state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
54
godot-extension/src/collision.rs
Normal file
54
godot-extension/src/collision.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
use godot::prelude::*;
|
||||||
|
use shared::collision::Circle;
|
||||||
|
use godot::classes::{ Node2D, INode2D };
|
||||||
|
|
||||||
|
#[derive(GodotClass)]
|
||||||
|
#[class(base=Node2D)]
|
||||||
|
pub struct DanmakuCircle {
|
||||||
|
pub circle_state: Circle,
|
||||||
|
base: Base<Node2D>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[godot_api]
|
||||||
|
impl INode2D for DanmakuCircle {
|
||||||
|
fn init(base: Base<Node2D>) -> Self {
|
||||||
|
Self {
|
||||||
|
circle_state: Circle::new(0.0, 0.0, 0.0),
|
||||||
|
base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[godot_api]
|
||||||
|
impl DanmakuCircle {
|
||||||
|
#[func]
|
||||||
|
fn setup_circle(&mut self, x: f64, y: f64, radius: f64) {
|
||||||
|
self.circle_state = Circle::new(x, y, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn get_position(&self) -> Vector2 {
|
||||||
|
Vector2::new(self.circle_state.x as f32, self.circle_state.y as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn set_position(&mut self, new_x: f64, new_y: f64) {
|
||||||
|
self.circle_state.x = new_x;
|
||||||
|
self.circle_state.y = new_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn get_radius(&self) -> f64 {
|
||||||
|
self.circle_state.radius
|
||||||
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn set_radius(&mut self, new_radius: f64) {
|
||||||
|
self.circle_state.radius = new_radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[func]
|
||||||
|
fn collides_with(&self, other: Gd<DanmakuCircle>) -> bool {
|
||||||
|
self.circle_state.collides_with(&other.bind().circle_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod bullet;
|
mod bullet;
|
||||||
|
mod collision;
|
||||||
|
|
||||||
use godot::prelude::*;
|
use godot::prelude::*;
|
||||||
struct Danmaku;
|
struct Danmaku;
|
||||||
|
|
|
||||||
|
|
@ -40,20 +40,22 @@ const (
|
||||||
// Interface for registering match handlers
|
// Interface for registering match handlers
|
||||||
type BattleRoyaleMatch struct{}
|
type BattleRoyaleMatch struct{}
|
||||||
|
|
||||||
type Position struct {
|
type PlayerStageState struct {
|
||||||
|
col *C.Circle
|
||||||
|
bullets []*C.Bullet
|
||||||
|
updatePlayerPos bool
|
||||||
|
health int
|
||||||
|
graze int
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerUpdate struct {
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerStageState struct {
|
|
||||||
pos Position
|
|
||||||
bullets []*C.Bullet
|
|
||||||
updatePlayerPos bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameTickUpdate struct {
|
type GameTickUpdate struct {
|
||||||
Tick int64 `json:"tick"`
|
Tick int64 `json:"tick"`
|
||||||
PlayerPos Position `json:"playerPos"`
|
PlayerPos map[string]interface{} `json:"playerPos"`
|
||||||
NewBullets []map[string]interface{} `json:"newBullets"`
|
NewBullets []map[string]interface{} `json:"newBullets"`
|
||||||
ForcePlayerPos bool `json:"forcePlayerPos"`
|
ForcePlayerPos bool `json:"forcePlayerPos"`
|
||||||
}
|
}
|
||||||
|
|
@ -109,12 +111,10 @@ func (m *BattleRoyaleMatch) MatchJoin(ctx context.Context, logger runtime.Logger
|
||||||
lobbyState.presences[presences[i].GetSessionId()] = &PresenceState{
|
lobbyState.presences[presences[i].GetSessionId()] = &PresenceState{
|
||||||
presence: presences[i],
|
presence: presences[i],
|
||||||
stageState: PlayerStageState{
|
stageState: PlayerStageState{
|
||||||
pos: Position{
|
col: C.new_circle(C.double(STAGE_WIDTH*0.5), C.double(STAGE_HEIGHT-STAGE_HEIGHT*0.1), C.double(STAGE_WIDTH*0.09)),
|
||||||
X: STAGE_WIDTH * 0.5,
|
|
||||||
Y: STAGE_HEIGHT - STAGE_HEIGHT*0.1,
|
|
||||||
},
|
|
||||||
bullets: []*C.Bullet{},
|
bullets: []*C.Bullet{},
|
||||||
updatePlayerPos: true,
|
updatePlayerPos: true,
|
||||||
|
health: 3,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ func (m *BattleRoyaleMatch) MatchLeave(ctx context.Context, logger runtime.Logge
|
||||||
for _, bullet := range playerState.stageState.bullets {
|
for _, bullet := range playerState.stageState.bullets {
|
||||||
C.destroy_bullet(bullet)
|
C.destroy_bullet(bullet)
|
||||||
}
|
}
|
||||||
|
C.destroy_circle(playerState.stageState.col)
|
||||||
delete(lobbyState.presences, sessionID)
|
delete(lobbyState.presences, sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,21 +197,21 @@ func (m *BattleRoyaleMatch) MatchLoop(ctx context.Context, logger runtime.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse player message
|
// Parse player message
|
||||||
var pos Position
|
var update PlayerUpdate
|
||||||
if err := json.Unmarshal(msg.GetData(), &pos); err != nil {
|
if err := json.Unmarshal(msg.GetData(), &update); err != nil {
|
||||||
logger.Warn("Failed to parse input: %v", err)
|
logger.Warn("Failed to parse input: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player movement bounds detection
|
// Player movement bounds detection
|
||||||
clampedX := pos.X < 0 || pos.X > STAGE_WIDTH
|
clampedX := update.X < 0 || update.X > STAGE_WIDTH
|
||||||
clampedY := pos.Y < 0 || pos.Y > STAGE_HEIGHT
|
clampedY := update.Y < 0 || update.Y > STAGE_HEIGHT
|
||||||
|
|
||||||
pos.X = math.Max(0, math.Min(pos.X, STAGE_WIDTH))
|
update.X = math.Max(0, math.Min(update.X, STAGE_WIDTH))
|
||||||
pos.Y = math.Max(0, math.Min(pos.Y, STAGE_HEIGHT))
|
update.Y = math.Max(0, math.Min(update.Y, STAGE_HEIGHT))
|
||||||
|
|
||||||
lobbyState.presences[msg.GetSessionId()].stageState.pos.X = pos.X
|
lobbyState.presences[msg.GetSessionId()].stageState.col.x = C.double(update.X)
|
||||||
lobbyState.presences[msg.GetSessionId()].stageState.pos.Y = pos.Y
|
lobbyState.presences[msg.GetSessionId()].stageState.col.y = C.double(update.Y)
|
||||||
|
|
||||||
if clampedX || clampedY {
|
if clampedX || clampedY {
|
||||||
lobbyState.presences[msg.GetSessionId()].stageState.updatePlayerPos = true
|
lobbyState.presences[msg.GetSessionId()].stageState.updatePlayerPos = true
|
||||||
|
|
@ -235,6 +235,7 @@ func (m *BattleRoyaleMatch) MatchLoop(ctx context.Context, logger runtime.Logger
|
||||||
if tick%10 == 0 {
|
if tick%10 == 0 {
|
||||||
velx := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
velx := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
||||||
vely := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
vely := (rand.Float64() * STAGE_WIDTH) / float64(lobbyState.tickRate)
|
||||||
|
radius := 0.03
|
||||||
vel_x_sign := 2*rand.Intn(2) - 1
|
vel_x_sign := 2*rand.Intn(2) - 1
|
||||||
vel_y_sign := 2*rand.Intn(2) - 1
|
vel_y_sign := 2*rand.Intn(2) - 1
|
||||||
|
|
||||||
|
|
@ -243,6 +244,7 @@ func (m *BattleRoyaleMatch) MatchLoop(ctx context.Context, logger runtime.Logger
|
||||||
C.int64_t(tick),
|
C.int64_t(tick),
|
||||||
C.double(STAGE_WIDTH*rand.Float64()),
|
C.double(STAGE_WIDTH*rand.Float64()),
|
||||||
C.double(STAGE_HEIGHT*rand.Float64()),
|
C.double(STAGE_HEIGHT*rand.Float64()),
|
||||||
|
C.double(radius),
|
||||||
C.double(float64(vel_x_sign)*velx),
|
C.double(float64(vel_x_sign)*velx),
|
||||||
C.double(float64(vel_y_sign)*vely),
|
C.double(float64(vel_y_sign)*vely),
|
||||||
)
|
)
|
||||||
|
|
@ -253,12 +255,13 @@ func (m *BattleRoyaleMatch) MatchLoop(ctx context.Context, logger runtime.Logger
|
||||||
C.bullet_get_current_pos(bullet, C.int64_t(tick), &x, &y)
|
C.bullet_get_current_pos(bullet, C.int64_t(tick), &x, &y)
|
||||||
|
|
||||||
bulletData := map[string]interface{}{
|
bulletData := map[string]interface{}{
|
||||||
"class": BULLET_LINEAR,
|
"class": BULLET_LINEAR,
|
||||||
"tick": tick,
|
"tick": tick,
|
||||||
"x": float64(x),
|
"x": float64(x),
|
||||||
"y": float64(y),
|
"y": float64(y),
|
||||||
"vel_x": float64(vel_x_sign) * velx,
|
"radius": float64(radius),
|
||||||
"vel_y": float64(vel_y_sign) * vely,
|
"vel_x": float64(vel_x_sign) * velx,
|
||||||
|
"vel_y": float64(vel_y_sign) * vely,
|
||||||
}
|
}
|
||||||
|
|
||||||
newBulletsToBroadcast = append(newBulletsToBroadcast, bulletData)
|
newBulletsToBroadcast = append(newBulletsToBroadcast, bulletData)
|
||||||
|
|
@ -266,9 +269,9 @@ func (m *BattleRoyaleMatch) MatchLoop(ctx context.Context, logger runtime.Logger
|
||||||
|
|
||||||
var tickData = GameTickUpdate{
|
var tickData = GameTickUpdate{
|
||||||
Tick: tick,
|
Tick: tick,
|
||||||
PlayerPos: Position{
|
PlayerPos: map[string]interface{}{
|
||||||
X: v.stageState.pos.X,
|
"x": v.stageState.col.x,
|
||||||
Y: v.stageState.pos.Y,
|
"y": v.stageState.col.y,
|
||||||
},
|
},
|
||||||
NewBullets: newBulletsToBroadcast,
|
NewBullets: newBulletsToBroadcast,
|
||||||
ForcePlayerPos: v.stageState.updatePlayerPos,
|
ForcePlayerPos: v.stageState.updatePlayerPos,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use crate::collision;
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Bullet {
|
pub struct Bullet {
|
||||||
|
|
@ -5,18 +7,20 @@ pub struct Bullet {
|
||||||
pub spawn_time: i64,
|
pub spawn_time: i64,
|
||||||
pub spawn_x: f64,
|
pub spawn_x: f64,
|
||||||
pub spawn_y: f64,
|
pub spawn_y: f64,
|
||||||
|
pub radius: f64,
|
||||||
pub parameters: [f64; 2],
|
pub parameters: [f64; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bullet {
|
impl Bullet {
|
||||||
pub const BULLET_LINEAR: u8 = 0;
|
pub const BULLET_LINEAR: u8 = 0;
|
||||||
|
|
||||||
pub fn new(class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, parameters: [f64; 2]) -> Self {
|
pub fn new(class: u8, spawn_time: i64, spawn_x: f64, spawn_y: f64, radius: f64, parameters: [f64; 2]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
class,
|
class,
|
||||||
spawn_time,
|
spawn_time,
|
||||||
spawn_x,
|
spawn_x,
|
||||||
spawn_y,
|
spawn_y,
|
||||||
|
radius,
|
||||||
parameters,
|
parameters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -43,4 +47,9 @@ impl Bullet {
|
||||||
|| y < -BULLET_KILL_BUFFER_WIDTH
|
|| y < -BULLET_KILL_BUFFER_WIDTH
|
||||||
|| y > STAGE_HEIGHT + BULLET_KILL_BUFFER_WIDTH
|
|| y > STAGE_HEIGHT + BULLET_KILL_BUFFER_WIDTH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn collides_with(&self, tick: i64, circle: &collision::Circle) -> bool {
|
||||||
|
let (current_x, current_y) = self.get_current_pos(tick);
|
||||||
|
circle.collides_with(&collision::Circle::new(current_x, current_y, self.radius))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
22
shared/src/collision.rs
Normal file
22
shared/src/collision.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Circle {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub radius: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Circle {
|
||||||
|
pub fn new(x: f64, y: f64, radius: f64) -> Self {
|
||||||
|
Circle { x, y, radius }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn collides_with(&self, other: &Circle) -> bool {
|
||||||
|
let dx = self.x - other.x;
|
||||||
|
let dy = self.y - other.y;
|
||||||
|
let distance_squared = dx * dx + dy * dy;
|
||||||
|
let radius_sum = self.radius + other.radius;
|
||||||
|
|
||||||
|
distance_squared <= radius_sum * radius_sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
pub mod bullet;
|
pub mod bullet;
|
||||||
|
pub mod collision;
|
||||||
Reference in a new issue