mirror of
https://github.com/Ukendio/jecs.git
synced 2025-04-24 17:10:03 +00:00
Add World:target (#39)
This commit is contained in:
parent
e86b4c7f4c
commit
cf0683cf03
3 changed files with 232 additions and 85 deletions
59
README.md
59
README.md
|
@ -22,47 +22,38 @@ jecs is a stupidly fast Entity Component System (ECS).
|
||||||
### Example
|
### Example
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
local world = World.new()
|
local world = jecs.World.new()
|
||||||
|
local pair = jecs.pair
|
||||||
|
|
||||||
local player = world:entity()
|
local ChildOf = world:component()
|
||||||
local opponent = world:entity()
|
local Name = world:component()
|
||||||
|
|
||||||
local Health = world:component()
|
local function parent(entity)
|
||||||
local Position = world:component()
|
return world:target(entity, ChildOf)
|
||||||
-- Notice how components can just be entities as well?
|
end
|
||||||
-- It allows you to model relationships easily!
|
local function name()
|
||||||
local Damage = world:entity()
|
|
||||||
local DamagedBy = world:entity()
|
|
||||||
|
|
||||||
world:set(player, Health, 100)
|
local alice = world:entity()
|
||||||
world:set(player, Damage, 8)
|
world:set(alice, Name, "alice")
|
||||||
world:set(player, Position, Vector3.new(0, 5, 0))
|
|
||||||
|
|
||||||
world:set(opponent, Health, 100)
|
local bob = world:entity()
|
||||||
world:set(opponent, Damage, 21)
|
world:add(bob, pair(ChildOf, alice))
|
||||||
world:set(opponent, Position, Vector3.new(0, 5, 3))
|
world:set(bob, Name, "bob")
|
||||||
|
|
||||||
for playerId, playerPosition, health in world:query(Position, Health) do
|
local sara = world:entity()
|
||||||
local totalDamage = 0
|
world:add(sara, pair(ChildOf, alice))
|
||||||
for opponentId, opponentPosition, damage in world:query(Position, Damage) do
|
world:set(sara, Name, "sara")
|
||||||
if playerId == opponentId then
|
|
||||||
continue
|
print(getName(parent(sara)))
|
||||||
end
|
|
||||||
if (playerPosition - opponentPosition).Magnitude < 5 then
|
for e in world:query(pair(ChildOf, alice)) do
|
||||||
totalDamage += damage
|
print(getName(e), "is the child of alice")
|
||||||
end
|
|
||||||
-- We create a pair between the relation component `DamagedBy` and the entity id of the opponent.
|
|
||||||
-- This will allow us to specifically query for damage exerted by a specific opponent.
|
|
||||||
world:set(playerId, ECS_PAIR(DamagedBy, opponentId), totalDamage)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Gets the damage inflicted by our specific opponent!
|
-- Output
|
||||||
for playerId, health, inflicted in world:query(Health, ECS_PAIR(DamagedBy, opponent)) do
|
-- "alice"
|
||||||
world:set(playerId, health - inflicted)
|
-- bob is the child of alice
|
||||||
end
|
-- sara is the child of alice
|
||||||
|
|
||||||
assert(world:get(player, Health) == 79)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
125 archetypes, 4 random components queried.
|
125 archetypes, 4 random components queried.
|
||||||
|
|
184
lib/init.lua
184
lib/init.lua
|
@ -14,7 +14,7 @@ type Column = {any}
|
||||||
type Archetype = {
|
type Archetype = {
|
||||||
id: number,
|
id: number,
|
||||||
edges: {
|
edges: {
|
||||||
[i24]: {
|
[i53]: {
|
||||||
add: Archetype,
|
add: Archetype,
|
||||||
remove: Archetype,
|
remove: Archetype,
|
||||||
},
|
},
|
||||||
|
@ -26,17 +26,37 @@ type Archetype = {
|
||||||
records: {},
|
records: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type Record = {
|
type Record = {
|
||||||
archetype: Archetype,
|
archetype: Archetype,
|
||||||
row: number,
|
row: number,
|
||||||
dense: i24,
|
dense: i24,
|
||||||
|
componentRecord: ArchetypeMap
|
||||||
}
|
}
|
||||||
|
|
||||||
type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}}
|
type EntityIndex = {dense: {[i24]: i53}, sparse: {[i53]: Record}}
|
||||||
type ComponentIndex = {[i24]: ArchetypeMap}
|
|
||||||
|
|
||||||
type ArchetypeRecord = number
|
type ArchetypeRecord = number
|
||||||
type ArchetypeMap = {sparse: {[ArchetypeId]: ArchetypeRecord}, size: number}
|
--[[
|
||||||
|
TODO:
|
||||||
|
{
|
||||||
|
index: number,
|
||||||
|
count: number,
|
||||||
|
column: number
|
||||||
|
}
|
||||||
|
|
||||||
|
]]
|
||||||
|
|
||||||
|
type ArchetypeMap = {
|
||||||
|
cache: {[number]: ArchetypeRecord},
|
||||||
|
first: ArchetypeMap,
|
||||||
|
second: ArchetypeMap,
|
||||||
|
parent: ArchetypeMap,
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComponentIndex = {[i24]: ArchetypeMap}
|
||||||
|
|
||||||
type Archetypes = {[ArchetypeId]: Archetype}
|
type Archetypes = {[ArchetypeId]: Archetype}
|
||||||
|
|
||||||
type ArchetypeDiff = {
|
type ArchetypeDiff = {
|
||||||
|
@ -96,6 +116,7 @@ local function ECS_GENERATION(e: i53)
|
||||||
return e % ECS_GENERATION_MASK
|
return e % ECS_GENERATION_MASK
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- SECOND
|
||||||
local function ECS_ENTITY_T_LO(e: i53)
|
local function ECS_ENTITY_T_LO(e: i53)
|
||||||
e //= 0x10
|
e //= 0x10
|
||||||
return e // ECS_ENTITY_MASK
|
return e // ECS_ENTITY_MASK
|
||||||
|
@ -107,7 +128,7 @@ local function ECS_GENERATION_INC(e: i53)
|
||||||
return ECS_COMBINE(id, generation + 1) + flags
|
return ECS_COMBINE(id, generation + 1) + flags
|
||||||
end
|
end
|
||||||
|
|
||||||
-- gets the high ID
|
-- FIRST gets the high ID
|
||||||
local function ECS_ENTITY_T_HI(entity: i53): i24
|
local function ECS_ENTITY_T_HI(entity: i53): i24
|
||||||
entity //= 0x10
|
entity //= 0x10
|
||||||
local first = entity % ECS_ENTITY_MASK
|
local first = entity % ECS_ENTITY_MASK
|
||||||
|
@ -131,8 +152,13 @@ local function ECS_PAIR(pred: number, obj: number)
|
||||||
ECS_ENTITY_T_LO(first), second) + addFlags(--[[isPair]] true)
|
ECS_ENTITY_T_LO(first), second) + addFlags(--[[isPair]] true)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function getAlive(entityIndex: EntityIndex, id: i53)
|
local function getAlive(entityIndex: EntityIndex, id: i24)
|
||||||
return entityIndex.dense[id]
|
local entityId = entityIndex.dense[id]
|
||||||
|
local record = entityIndex.sparse[entityIndex.dense[id]]
|
||||||
|
if not record then
|
||||||
|
error(id.." is not alive")
|
||||||
|
end
|
||||||
|
return entityId
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits
|
-- ECS_PAIR_FIRST, gets the relationship target / obj / HIGH bits
|
||||||
|
@ -239,17 +265,29 @@ local function hash(arr): string | number
|
||||||
return table.concat(arr, "_")
|
return table.concat(arr, "_")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createArchetypeRecord(componentIndex, id, componentId, i)
|
local function ensureComponentRecord(componentIndex: ComponentIndex, archetypeId, componentId, i): ArchetypeMap
|
||||||
local archetypesMap = componentIndex[componentId]
|
local archetypesMap = componentIndex[componentId]
|
||||||
|
|
||||||
if not archetypesMap then
|
if not archetypesMap then
|
||||||
archetypesMap = {size = 0, sparse = {}}
|
archetypesMap = {size = 0, cache = {}, first = {}, second = {}} :: ArchetypeMap
|
||||||
componentIndex[componentId] = archetypesMap
|
componentIndex[componentId] = archetypesMap
|
||||||
end
|
end
|
||||||
archetypesMap.sparse[id] = i
|
|
||||||
|
archetypesMap.cache[archetypeId] = i
|
||||||
|
archetypesMap.size += 1
|
||||||
|
|
||||||
|
return archetypesMap
|
||||||
end
|
end
|
||||||
|
|
||||||
local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archetype
|
local function ECS_ID_IS_WILDCARD(e)
|
||||||
|
assert(ECS_IS_PAIR(e))
|
||||||
|
local first = ECS_ENTITY_T_HI(e)
|
||||||
|
local second = ECS_ENTITY_T_LO(e)
|
||||||
|
return first == WILDCARD or second == WILDCARD
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local function archetypeOf(world: any, types: {i24}, prev: Archetype?): Archetype
|
||||||
local ty = hash(types)
|
local ty = hash(types)
|
||||||
|
|
||||||
local id = world.nextArchetypeId + 1
|
local id = world.nextArchetypeId + 1
|
||||||
|
@ -257,25 +295,27 @@ local function archetypeOf(world: World, types: {i24}, prev: Archetype?): Archet
|
||||||
|
|
||||||
local length = #types
|
local length = #types
|
||||||
local columns = table.create(length)
|
local columns = table.create(length)
|
||||||
|
local componentIndex = world.componentIndex
|
||||||
|
|
||||||
local records = {}
|
local records = {}
|
||||||
local componentIndex = world.componentIndex
|
|
||||||
local entityIndex = world.entityIndex
|
|
||||||
for i, componentId in types do
|
for i, componentId in types do
|
||||||
createArchetypeRecord(componentIndex, id, componentId, i)
|
ensureComponentRecord(componentIndex, id, componentId, i)
|
||||||
records[componentId] = i
|
records[componentId] = i
|
||||||
columns[i] = {}
|
|
||||||
|
|
||||||
if ECS_IS_PAIR(componentId) then
|
if ECS_IS_PAIR(componentId) then
|
||||||
local pred = ECS_PAIR_RELATION(entityIndex, componentId)
|
local relation = ECS_PAIR_RELATION(world.entityIndex, componentId)
|
||||||
local obj = ECS_PAIR_OBJECT(entityIndex, componentId)
|
local object = ECS_PAIR_OBJECT(world.entityIndex, componentId)
|
||||||
local first = ECS_PAIR(pred, WILDCARD)
|
|
||||||
local second = ECS_PAIR(WILDCARD, obj)
|
local idr_r = ECS_PAIR(relation, WILDCARD)
|
||||||
createArchetypeRecord(componentIndex, id, first, i)
|
ensureComponentRecord(
|
||||||
createArchetypeRecord(componentIndex, id, second, i)
|
componentIndex, id, idr_r, i)
|
||||||
records[first] = i
|
records[idr_r] = i
|
||||||
records[second] = i
|
|
||||||
|
local idr_t = ECS_PAIR(WILDCARD, object)
|
||||||
|
ensureComponentRecord(
|
||||||
|
componentIndex, id, idr_t, i)
|
||||||
|
records[idr_t] = i
|
||||||
end
|
end
|
||||||
|
columns[i] = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local archetype = {
|
local archetype = {
|
||||||
|
@ -333,6 +373,29 @@ function World.entity(world: World)
|
||||||
return nextEntityId(world.entityIndex, entityId + REST)
|
return nextEntityId(world.entityIndex, entityId + REST)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- TODO:
|
||||||
|
-- should have an additional `index` parameter which selects the nth target
|
||||||
|
-- this is important when an entity can have multiple relationships with the same target
|
||||||
|
function World.target(world: World, entity: i53, relation: i24): i24?
|
||||||
|
local entityIndex = world.entityIndex
|
||||||
|
local record = entityIndex.sparse[entity]
|
||||||
|
local archetype = record.archetype
|
||||||
|
if not archetype then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local componentRecord = world.componentIndex[ECS_PAIR(relation, WILDCARD)]
|
||||||
|
if not componentRecord then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local archetypeRecord = componentRecord.cache[archetype.id]
|
||||||
|
if not archetypeRecord then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return ECS_PAIR_OBJECT(entityIndex, archetype.types[archetypeRecord])
|
||||||
|
end
|
||||||
|
|
||||||
-- should reuse this logic in World.set instead of swap removing in transition archetype
|
-- should reuse this logic in World.set instead of swap removing in transition archetype
|
||||||
local function destructColumns(columns, count, row)
|
local function destructColumns(columns, count, row)
|
||||||
if row == count then
|
if row == count then
|
||||||
|
@ -347,41 +410,54 @@ local function destructColumns(columns, count, row)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function archetypeDelete(entityIndex, record: Record, entityId: i53, destruct: boolean)
|
local function archetypeDelete(world: World, id: i53)
|
||||||
|
local componentIndex = world.componentIndex
|
||||||
|
local archetypesMap = componentIndex[id]
|
||||||
|
local archetypes = world.archetypes
|
||||||
|
if archetypesMap then
|
||||||
|
for archetypeId in archetypesMap.cache do
|
||||||
|
for _, entity in archetypes[archetypeId].entities do
|
||||||
|
world:remove(entity, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
componentIndex[id] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function World.delete(world: World, entityId: i53)
|
||||||
|
local record = world.entityIndex.sparse[entityId]
|
||||||
|
if not record then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local entityIndex = world.entityIndex
|
||||||
local sparse, dense = entityIndex.sparse, entityIndex.dense
|
local sparse, dense = entityIndex.sparse, entityIndex.dense
|
||||||
local archetype = record.archetype
|
local archetype = record.archetype
|
||||||
local row = record.row
|
local row = record.row
|
||||||
local entities = archetype.entities
|
|
||||||
local last = #entities
|
|
||||||
|
|
||||||
local entityToMove = entities[last]
|
archetypeDelete(world, entityId)
|
||||||
|
archetypeDelete(world, ECS_PAIR(entityId, WILDCARD))
|
||||||
|
archetypeDelete(world, ECS_PAIR(WILDCARD, entityId))
|
||||||
|
|
||||||
if row ~= last then
|
if archetype then
|
||||||
dense[record.dense] = entityToMove
|
local entities = archetype.entities
|
||||||
sparse[entityToMove] = record
|
local last = #entities
|
||||||
|
|
||||||
|
if row ~= last then
|
||||||
|
local entityToMove = entities[last]
|
||||||
|
dense[record.dense] = entityToMove
|
||||||
|
sparse[entityToMove] = record
|
||||||
|
end
|
||||||
|
|
||||||
|
entities[row], entities[last] = entities[last], nil
|
||||||
|
|
||||||
|
local columns = archetype.columns
|
||||||
|
|
||||||
|
destructColumns(columns, last, row)
|
||||||
end
|
end
|
||||||
|
|
||||||
sparse[entityId] = nil
|
sparse[entityId] = nil
|
||||||
dense[#dense] = nil
|
dense[#dense] = nil
|
||||||
|
|
||||||
entities[row], entities[last] = entities[last], nil
|
|
||||||
|
|
||||||
local columns = archetype.columns
|
|
||||||
|
|
||||||
if not destruct then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
destructColumns(columns, last, row)
|
|
||||||
end
|
|
||||||
|
|
||||||
function World.delete(world: World, entityId: i53)
|
|
||||||
local entityIndex = world.entityIndex
|
|
||||||
local record = entityIndex.sparse[entityId]
|
|
||||||
if not record then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
archetypeDelete(entityIndex, record, entityId, true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
export type World = typeof(World.new())
|
export type World = typeof(World.new())
|
||||||
|
@ -530,6 +606,10 @@ end
|
||||||
-- Keeping the function as small as possible to enable inlining
|
-- Keeping the function as small as possible to enable inlining
|
||||||
local function get(record: Record, componentId: i24)
|
local function get(record: Record, componentId: i24)
|
||||||
local archetype = record.archetype
|
local archetype = record.archetype
|
||||||
|
if not archetype then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local archetypeRecord = archetype.records[componentId]
|
local archetypeRecord = archetype.records[componentId]
|
||||||
|
|
||||||
if not archetypeRecord then
|
if not archetypeRecord then
|
||||||
|
@ -575,7 +655,7 @@ EmptyQuery.__index = EmptyQuery
|
||||||
setmetatable(EmptyQuery, EmptyQuery)
|
setmetatable(EmptyQuery, EmptyQuery)
|
||||||
|
|
||||||
export type Query = typeof(EmptyQuery)
|
export type Query = typeof(EmptyQuery)
|
||||||
|
local testkit = require("../testkit")
|
||||||
function World.query(world: World, ...: i53): Query
|
function World.query(world: World, ...: i53): Query
|
||||||
-- breaking?
|
-- breaking?
|
||||||
if (...) == nil then
|
if (...) == nil then
|
||||||
|
@ -603,9 +683,10 @@ function World.query(world: World, ...: i53): Query
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for id in firstArchetypeMap.sparse do
|
for id in firstArchetypeMap.cache do
|
||||||
local archetype = archetypes[id]
|
local archetype = archetypes[id]
|
||||||
local archetypeRecords = archetype.records
|
local archetypeRecords = archetype.records
|
||||||
|
|
||||||
local indices = {}
|
local indices = {}
|
||||||
local skip = false
|
local skip = false
|
||||||
|
|
||||||
|
@ -615,6 +696,7 @@ function World.query(world: World, ...: i53): Query
|
||||||
skip = true
|
skip = true
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
|
-- index should be index.offset
|
||||||
indices[i] = index
|
indices[i] = index
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
local testkit = require("../testkit")
|
local testkit = require("../testkit")
|
||||||
local jecs = require("../lib/init")
|
local jecs = require("../lib/init")
|
||||||
|
local __ = jecs.Wildcard
|
||||||
local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION
|
local ECS_ID, ECS_GENERATION = jecs.ECS_ID, jecs.ECS_GENERATION
|
||||||
local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC
|
local ECS_GENERATION_INC = jecs.ECS_GENERATION_INC
|
||||||
local IS_PAIR = jecs.IS_PAIR
|
local IS_PAIR = jecs.IS_PAIR
|
||||||
|
@ -9,7 +10,16 @@ local ECS_PAIR_RELATION = jecs.ECS_PAIR_RELATION
|
||||||
local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT
|
local ECS_PAIR_OBJECT = jecs.ECS_PAIR_OBJECT
|
||||||
|
|
||||||
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
|
local TEST, CASE, CHECK, FINISH, SKIP = testkit.test()
|
||||||
|
local function CHECK_NO_ERR<T...>(s: string, fn: (T...) -> (), ...: T...)
|
||||||
|
local ok, err: string? = pcall(fn, ...)
|
||||||
|
|
||||||
|
if not CHECK(not ok, 2) then
|
||||||
|
local i = string.find(err :: string, " ")
|
||||||
|
assert(i)
|
||||||
|
local msg = string.sub(err :: string, i+1)
|
||||||
|
CHECK(msg == s, 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
local N = 10
|
local N = 10
|
||||||
|
|
||||||
TEST("world", function()
|
TEST("world", function()
|
||||||
|
@ -256,6 +266,70 @@ TEST("world", function()
|
||||||
end
|
end
|
||||||
CHECK(count == 1)
|
CHECK(count == 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do CASE "should only relate alive entities"
|
||||||
|
|
||||||
|
local world = jecs.World.new()
|
||||||
|
local Eats = world:entity()
|
||||||
|
local Apples = world:entity()
|
||||||
|
local Oranges = world:entity()
|
||||||
|
local bob = world:entity()
|
||||||
|
local alice = world:entity()
|
||||||
|
|
||||||
|
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
|
||||||
|
world:set(alice, ECS_PAIR(Eats, Oranges), "alice eats oranges")
|
||||||
|
|
||||||
|
world:delete(Apples)
|
||||||
|
local Wildcard = jecs.Wildcard
|
||||||
|
|
||||||
|
local count = 0
|
||||||
|
for _, data in world:query(ECS_PAIR(Wildcard, Apples)) do
|
||||||
|
count += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
CHECK(count == 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "should error when setting invalid pair"
|
||||||
|
local world = jecs.World.new()
|
||||||
|
local Eats = world:entity()
|
||||||
|
local Apples = world:entity()
|
||||||
|
local bob = world:entity()
|
||||||
|
|
||||||
|
world:delete(Apples)
|
||||||
|
|
||||||
|
CHECK_NO_ERR("Apples should be dead", function()
|
||||||
|
world:set(bob, ECS_PAIR(Eats, Apples), "bob eats apples")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
do CASE "should find target for ChildOf"
|
||||||
|
local world = jecs.World.new()
|
||||||
|
|
||||||
|
local ChildOf = world:component()
|
||||||
|
local Name = world:component()
|
||||||
|
|
||||||
|
local function parent(entity)
|
||||||
|
return world:target(entity, ChildOf)
|
||||||
|
end
|
||||||
|
|
||||||
|
local bob = world:entity()
|
||||||
|
local alice = world:entity()
|
||||||
|
local sara = world:entity()
|
||||||
|
|
||||||
|
world:add(bob, ECS_PAIR(ChildOf, alice))
|
||||||
|
world:set(bob, Name, "bob")
|
||||||
|
world:add(sara, ECS_PAIR(ChildOf, alice))
|
||||||
|
world:set(sara, Name, "sara")
|
||||||
|
CHECK(parent(bob) == alice) -- O(1)
|
||||||
|
|
||||||
|
local count = 0
|
||||||
|
for _, name in world:query(Name, ECS_PAIR(ChildOf, alice)) do
|
||||||
|
print(name)
|
||||||
|
count += 1
|
||||||
|
end
|
||||||
|
CHECK(count == 2)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
FINISH()
|
FINISH()
|
Loading…
Reference in a new issue