another step in verb implementation:
- objectMap responds to mouse move (but respond script not run well ?) loadStrings add some special count check - so all other LUT based resource should implement this technique svn-id: r16594
This commit is contained in:
parent
c1ce30b0bf
commit
b4b2e52df7
9 changed files with 111 additions and 140 deletions
|
@ -276,9 +276,9 @@ public:
|
|||
|
||||
void cmdActorWalkTo(int argc, const char **argv);
|
||||
|
||||
bool validActorId(uint16 id) { return (id == 1) || ((id >= 0x2000) && (id < (0x2000 | _actorsCount))); }
|
||||
int actorIdToIndex(uint16 id) { return (id == 1 ) ? 0 : (id & ~0x2000); }
|
||||
uint16 actorIndexToId(int index) { return (index == 0 ) ? 1 : (index | 0x2000); }
|
||||
bool validActorId(uint16 id) { return (id == ID_PROTAG) || ((id > OBJECT_TYPE_MASK) && (id < objectIndexToId(kGameObjectActor, _actorsCount))); }
|
||||
int actorIdToIndex(uint16 id) { return (id == ID_PROTAG ) ? 0 : objectIdToIndex(id); }
|
||||
uint16 actorIndexToId(int index) { return (index == 0 ) ? ID_PROTAG : objectIndexToId(kGameObjectActor, index); }
|
||||
|
||||
int direct(int msec);
|
||||
int drawActors();
|
||||
|
|
|
@ -478,84 +478,6 @@ void Interface::handleCommandUpdate(SURFACE *ds, const Point& mousePoint) {
|
|||
|
||||
}
|
||||
|
||||
int Interface::handlePlayfieldClick(SURFACE *ds, const Point& imousePt) {
|
||||
// return SUCCESS;
|
||||
|
||||
int objectNum;
|
||||
uint16 object_flags = 0;
|
||||
|
||||
// int script_num;
|
||||
Location location;
|
||||
|
||||
objectNum = _vm->_scene->_objectMap->hitTest(imousePt);
|
||||
|
||||
if (objectNum == -1) {
|
||||
// Player clicked on empty spot - walk here regardless of verb
|
||||
location.fromScreenPoint(imousePt);
|
||||
|
||||
_vm->_actor->actorWalkTo(ID_PROTAG, location);
|
||||
return SUCCESS;
|
||||
}
|
||||
|
||||
// object_flags = _vm->_scene->_objectMap->getFlags(objectNum);
|
||||
|
||||
if (object_flags & kHitZoneExit) { // FIXME. This is wrong
|
||||
/* if ((script_num = _vm->_scene->_objectMap->getEPNum(objectNum)) != -1) {
|
||||
// Set active verb in script module
|
||||
_vm->_script->putWord(4, 4, I_VerbData[_activeVerb].s_verb);
|
||||
|
||||
// Execute object script if present
|
||||
if (script_num != 0) {
|
||||
_vm->_script->SThreadExecute(_iThread, script_num);
|
||||
}
|
||||
}*/
|
||||
} else {
|
||||
// Not a normal scene object - walk to it as if it weren't there
|
||||
location.fromScreenPoint(imousePt);
|
||||
|
||||
_vm->_actor->actorWalkTo(ID_PROTAG, location);
|
||||
}
|
||||
|
||||
return SUCCESS;
|
||||
}
|
||||
|
||||
int Interface::handlePlayfieldUpdate(SURFACE *ds, const Point& imousePt) {
|
||||
return SUCCESS;
|
||||
/*
|
||||
const char *object_name;
|
||||
int objectNum;
|
||||
uint16 object_flags = 0;
|
||||
|
||||
char new_status[STATUS_TEXT_LEN];
|
||||
|
||||
new_status[0] = 0;
|
||||
|
||||
objectNum = _vm->_scene->_objectMap->hitTest(imousePt);
|
||||
|
||||
if (objectNum == -1) {
|
||||
// Cursor over nothing - just display current verb
|
||||
setStatusText(I_VerbData[_activeVerb].verb_str);
|
||||
return SUCCESS;
|
||||
}
|
||||
|
||||
object_flags = _vm->_scene->_objectMap->getFlags(objectNum);
|
||||
object_name = _vm->_scene->_objectMap->getName(objectNum);
|
||||
|
||||
if (object_flags & OBJECT_EXIT) { // FIXME. This is wrong
|
||||
// Normal scene object - display as subject of verb
|
||||
snprintf(new_status, STATUS_TEXT_LEN, "%s %s", I_VerbData[_activeVerb].verb_str, object_name);
|
||||
} else {
|
||||
// Not normal scene object - override verb as we can only
|
||||
// walk to this object
|
||||
snprintf(new_status, STATUS_TEXT_LEN, "%s %s", I_VerbData[I_VERB_WALKTO].verb_str, object_name);
|
||||
}
|
||||
|
||||
setStatusText(new_status);
|
||||
|
||||
return SUCCESS;
|
||||
*/
|
||||
}
|
||||
|
||||
PanelButton *Interface::verbHitTest(const Point& mousePoint) {
|
||||
PanelButton *panelButton;
|
||||
Rect rect;
|
||||
|
|
|
@ -124,8 +124,6 @@ private:
|
|||
PanelButton *verbHitTest(const Point& mousePoint);
|
||||
void handleCommandUpdate(SURFACE *ds, const Point& mousePoint);
|
||||
void handleCommandClick(SURFACE *ds, const Point& mousePoint);
|
||||
int handlePlayfieldUpdate(SURFACE *ds, const Point& imousePt);
|
||||
int handlePlayfieldClick(SURFACE *ds, const Point& imousePt);
|
||||
|
||||
void lockMode() { _lockedMode = _panelMode; }
|
||||
void unlockMode() { _panelMode = _lockedMode; }
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
|
||||
namespace Saga {
|
||||
|
||||
HitZone::HitZone(MemoryReadStreamEndian *readStream) {
|
||||
HitZone::HitZone(MemoryReadStreamEndian *readStream, int index): _index(index) {
|
||||
int i, j;
|
||||
HitZone::ClickArea *clickArea;
|
||||
Point *point;
|
||||
|
@ -45,7 +45,7 @@ HitZone::HitZone(MemoryReadStreamEndian *readStream) {
|
|||
_clickAreasCount = readStream->readByte();
|
||||
_rightButtonVerb = readStream->readByte();
|
||||
readStream->readByte(); // pad
|
||||
_nameNumber = readStream->readUint16();
|
||||
_nameIndex = readStream->readUint16();
|
||||
_scriptNumber = readStream->readUint16();
|
||||
|
||||
_clickAreas = (HitZone::ClickArea *)malloc(_clickAreasCount * sizeof(*_clickAreas));
|
||||
|
@ -80,6 +80,22 @@ HitZone::~HitZone() {
|
|||
free(_clickAreas);
|
||||
}
|
||||
|
||||
bool HitZone::getSpecialPoint(Point &specialPoint) const {
|
||||
int i, pointsCount;
|
||||
HitZone::ClickArea *clickArea;
|
||||
Point *points;
|
||||
|
||||
for (i = 0; i < _clickAreasCount; i++) {
|
||||
clickArea = &_clickAreas[i];
|
||||
pointsCount = clickArea->pointsCount;
|
||||
points = clickArea->points;
|
||||
if (pointsCount == 1) {
|
||||
specialPoint = points[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool HitZone::hitTest(const Point &testPoint) {
|
||||
int i, pointsCount;
|
||||
HitZone::ClickArea *clickArea;
|
||||
|
@ -157,7 +173,7 @@ void ObjectMap::load(const byte *resourcePointer, size_t resourceLength) {
|
|||
}
|
||||
|
||||
for (i = 0; i < _hitZoneListCount; i++) {
|
||||
_hitZoneList[i] = new HitZone(&readS);
|
||||
_hitZoneList[i] = new HitZone(&readS, i);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -39,11 +39,14 @@ private:
|
|||
};
|
||||
public:
|
||||
|
||||
HitZone(MemoryReadStreamEndian *readStream);
|
||||
HitZone(MemoryReadStreamEndian *readStream, int index);
|
||||
~HitZone();
|
||||
|
||||
int getNameIndex() const {
|
||||
return _nameIndex;
|
||||
}
|
||||
int getSceneNumber() const {
|
||||
return _nameNumber;
|
||||
return _nameIndex;
|
||||
}
|
||||
int getActorsEntrance() const {
|
||||
return _scriptNumber;
|
||||
|
@ -60,14 +63,22 @@ public:
|
|||
int getDirection() const {
|
||||
return ((_flags >> 4) & 0xF);
|
||||
}
|
||||
uint16 getHitZoneId() const {
|
||||
return objectIndexToId(kGameObjectHitZone, _index);
|
||||
}
|
||||
uint16 getStepZoneId() const {
|
||||
return objectIndexToId(kGameObjectStepZone, _index);
|
||||
}
|
||||
bool getSpecialPoint(Point &specialPoint) const;
|
||||
void draw(SURFACE *ds, int color);
|
||||
bool hitTest(const Point &testPoint);
|
||||
private:
|
||||
int _flags; // HitZoneFlags
|
||||
int _flags; // Saga::HitZoneFlags
|
||||
int _clickAreasCount;
|
||||
int _rightButtonVerb;
|
||||
int _nameNumber;
|
||||
int _nameIndex;
|
||||
int _scriptNumber;
|
||||
int _index;
|
||||
|
||||
ClickArea *_clickAreas;
|
||||
};
|
||||
|
|
|
@ -51,6 +51,7 @@
|
|||
#include "saga/sound.h"
|
||||
#include "saga/music.h"
|
||||
#include "saga/palanim.h"
|
||||
#include "saga/objectmap.h"
|
||||
|
||||
static const GameSettings saga_games[] = {
|
||||
{"ite", "Inherit the Earth", 0},
|
||||
|
@ -316,45 +317,46 @@ int SagaEngine::go() {
|
|||
|
||||
void SagaEngine::loadStrings(StringsTable &stringsTable, const byte *stringsPointer, size_t stringsLength) {
|
||||
uint16 stringsCount;
|
||||
uint16 i;
|
||||
size_t offset;
|
||||
|
||||
int i;
|
||||
|
||||
stringsTable.stringsPointer = (byte*)malloc(stringsLength);
|
||||
memcpy(stringsTable.stringsPointer, stringsPointer, stringsLength);
|
||||
|
||||
|
||||
MemoryReadStreamEndian scriptS(stringsTable.stringsPointer, stringsLength, IS_BIG_ENDIAN);
|
||||
|
||||
offset = scriptS.readUint16();
|
||||
if (offset > stringsLength) {
|
||||
error("Invalid string offset");
|
||||
}
|
||||
|
||||
stringsCount = offset / 2 - 2;
|
||||
stringsTable.stringsCount = stringsCount;
|
||||
|
||||
stringsTable.strings = (const char **)malloc(stringsCount * sizeof(const char *));
|
||||
if (stringsTable.strings == NULL) {
|
||||
error("No enough memory for strings Table");
|
||||
}
|
||||
|
||||
stringsCount = offset / 2;
|
||||
stringsTable.strings = (const char **)malloc(stringsCount * sizeof(*stringsTable.strings));
|
||||
i = 0;
|
||||
scriptS.seek(0);
|
||||
for (i = 0; i < stringsCount; i++) {
|
||||
while (i < stringsCount) {
|
||||
offset = scriptS.readUint16();
|
||||
if (offset == stringsLength) {
|
||||
stringsCount = i;
|
||||
stringsTable.strings = (const char **)realloc(stringsTable.strings, stringsCount * sizeof(*stringsTable.strings));
|
||||
break;
|
||||
}
|
||||
if (offset > stringsLength) {
|
||||
error("invalid string offset");
|
||||
error("SagaEngine::loadStrings wrong strings table");
|
||||
}
|
||||
stringsTable.strings[i] = (const char *)stringsTable.stringsPointer + offset;
|
||||
debug(9, "string[%i]=%s", i, stringsTable.strings[i]);
|
||||
i++;
|
||||
}
|
||||
stringsTable.stringsCount = stringsCount;
|
||||
}
|
||||
|
||||
const char *SagaEngine::getObjectName(uint16 objectId) {
|
||||
|
||||
switch (objectIdType(objectId)) {
|
||||
const HitZone *hitZone;
|
||||
switch (objectTypeId(objectId)) {
|
||||
case kGameObjectActor:
|
||||
return _actor->getActorName(objectId);
|
||||
break;
|
||||
case kGameObjectHitZone:
|
||||
hitZone = _vm->_scene->_objectMap->getHitZone(objectIdToIndex(objectId));
|
||||
return _vm->_scene->_sceneStrings.getString(hitZone->getNameIndex());
|
||||
}
|
||||
//todo: object name & etc
|
||||
return NULL;
|
||||
|
@ -362,7 +364,7 @@ const char *SagaEngine::getObjectName(uint16 objectId) {
|
|||
|
||||
int SagaEngine::getObjectScriptEntrypointNumber(uint16 objectId) {
|
||||
ActorData *actor;
|
||||
switch (objectIdType(objectId)) {
|
||||
switch (objectTypeId(objectId)) {
|
||||
case kGameObjectActor:
|
||||
actor = _vm->_actor->getActor(objectId);
|
||||
return actor->scriptEntrypointNumber;
|
||||
|
@ -374,7 +376,7 @@ int SagaEngine::getObjectScriptEntrypointNumber(uint16 objectId) {
|
|||
|
||||
int SagaEngine::getObjectFlags(uint16 objectId) {
|
||||
ActorData *actor;
|
||||
if (objectIdType(objectId) == kGameObjectActor) {
|
||||
if (objectTypeId(objectId) == kGameObjectActor) {
|
||||
actor = _vm->_actor->getActor(objectId);
|
||||
return actor->flags;
|
||||
}
|
||||
|
|
17
saga/saga.h
17
saga/saga.h
|
@ -72,6 +72,8 @@ class PalAnim;
|
|||
|
||||
#define ID_NOTHING 0
|
||||
#define ID_PROTAG 1
|
||||
#define OBJECT_TYPE_SHIFT 13
|
||||
#define OBJECT_TYPE_MASK ((1 << OBJECT_TYPE_SHIFT) - 1)
|
||||
|
||||
struct RSCFILE_CONTEXT;
|
||||
struct StringList;
|
||||
|
@ -195,7 +197,7 @@ struct StringsTable {
|
|||
const char **strings;
|
||||
|
||||
const char *getString(int index) const {
|
||||
if (stringsCount <= index)
|
||||
if ((stringsCount <= index) || (index < 0))
|
||||
error("StringList::getString wrong index 0x%X", index);
|
||||
return strings[index];
|
||||
}
|
||||
|
@ -393,10 +395,19 @@ inline int integerCompare(int i1, int i2) {
|
|||
return ((i1) > (i2) ? 1 : ((i1) < (i2) ? -1 : 0));
|
||||
}
|
||||
|
||||
inline int objectIdType(uint16 objectId) {
|
||||
return objectId >> 13;
|
||||
inline int objectTypeId(uint16 objectId) {
|
||||
return objectId >> OBJECT_TYPE_SHIFT;
|
||||
}
|
||||
|
||||
inline int objectIdToIndex(uint16 objectId) {
|
||||
return OBJECT_TYPE_MASK & objectId;
|
||||
}
|
||||
|
||||
inline uint16 objectIndexToId(int type, int index) {
|
||||
return (type << OBJECT_TYPE_SHIFT) | (OBJECT_TYPE_MASK & index);
|
||||
}
|
||||
|
||||
|
||||
DetectedGameList GAME_ProbeGame(const FSList &fslist);
|
||||
|
||||
class SagaEngine : public Engine {
|
||||
|
|
|
@ -292,7 +292,6 @@ class Scene {
|
|||
SCENE_IMAGE _bg;
|
||||
SCENE_IMAGE _bgMask;
|
||||
|
||||
StringsTable _sceneStrings;
|
||||
int _sceneDoors[SCENE_DOORS_MAX];
|
||||
|
||||
static int SC_defaultScene(int param, SCENE_INFO *scene_info, void *refCon);
|
||||
|
@ -302,6 +301,7 @@ class Scene {
|
|||
ObjectMap *_actionMap;
|
||||
ObjectMap *_objectMap;
|
||||
SceneEntryList _entryList;
|
||||
StringsTable _sceneStrings;
|
||||
|
||||
private:
|
||||
int IHNMStartProc();
|
||||
|
|
|
@ -577,7 +577,7 @@ void Script::showVerb() {
|
|||
|
||||
verbName = _mainStrings.getString(_leftButtonVerb - 1);
|
||||
|
||||
if (objectIdType(_currentObject[0]) == kGameObjectNone) {
|
||||
if (objectTypeId(_currentObject[0]) == kGameObjectNone) {
|
||||
_vm->_interface->setStatusText(verbName);
|
||||
return;
|
||||
}
|
||||
|
@ -591,7 +591,7 @@ void Script::showVerb() {
|
|||
}
|
||||
|
||||
|
||||
if (objectIdType(_currentObject[1]) != kGameObjectNone) {
|
||||
if (objectTypeId(_currentObject[1]) != kGameObjectNone) {
|
||||
object2Name = _vm->getObjectName(_currentObject[1]);
|
||||
} else {
|
||||
object2Name = "";
|
||||
|
@ -638,7 +638,7 @@ void Script::setLeftButtonVerb(int verb) {
|
|||
}
|
||||
|
||||
void Script::setRightButtonVerb(int verb) {
|
||||
int oldVerb = _currentVerb;
|
||||
int oldVerb = _rightButtonVerb;
|
||||
|
||||
_rightButtonVerb = verb;
|
||||
|
||||
|
@ -658,8 +658,9 @@ void Script::doVerb() {
|
|||
EVENT event;
|
||||
const char *excuseText;
|
||||
int excuseSampleResourceId;
|
||||
const HitZone *hitZone;
|
||||
|
||||
objectType = objectIdType(_pendingObject[0]);
|
||||
objectType = objectTypeId(_pendingObject[0]);
|
||||
|
||||
if (_pendingVerb == kVerbGive) {
|
||||
scriptEntrypointNumber = _vm->getObjectScriptEntrypointNumber(_pendingObject[1]);
|
||||
|
@ -670,15 +671,19 @@ void Script::doVerb() {
|
|||
}
|
||||
} else {
|
||||
if (_pendingVerb == kVerbUse) {
|
||||
if ((objectIdType(_pendingObject[1]) > kGameObjectNone) && (objectType < objectIdType(_pendingObject[1]))) {
|
||||
if ((objectTypeId(_pendingObject[1]) > kGameObjectNone) && (objectType < objectTypeId(_pendingObject[1]))) {
|
||||
SWAP(_pendingObject[0], _pendingObject[1]);
|
||||
objectType = objectIdType(_pendingObject[0]);
|
||||
objectType = objectTypeId(_pendingObject[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (objectType == kGameObjectHitZone) {
|
||||
scriptModuleNumber = _vm->_scene->getScriptModuleNumber();
|
||||
//TODO: check HitZone Exit
|
||||
hitZone = _vm->_scene->_objectMap->getHitZone(objectIdToIndex(_pendingObject[0]));
|
||||
if ((hitZone->getFlags() & kHitZoneExit) == 0) {
|
||||
scriptEntrypointNumber = hitZone->getScriptNumber();
|
||||
}
|
||||
|
||||
} else {
|
||||
if (objectType & (kGameObjectActor | kGameObjectObject)) {
|
||||
scriptEntrypointNumber = _vm->getObjectScriptEntrypointNumber(_pendingObject[0]);
|
||||
|
@ -794,6 +799,7 @@ void Script::hitObject(bool leftButton) {
|
|||
void Script::playfieldClick(const Point& mousePoint, bool leftButton) {
|
||||
Location pickLocation;
|
||||
const HitZone *hitZone;
|
||||
Point specialPoint;
|
||||
|
||||
_vm->_actor->abortSpeech();
|
||||
|
||||
|
@ -826,11 +832,11 @@ void Script::playfieldClick(const Point& mousePoint, bool leftButton) {
|
|||
|
||||
hitZone = NULL;
|
||||
|
||||
if (objectIdType(_pendingObject[0]) == kGameObjectHitZone) {
|
||||
// hitZone = _vm->_scene->_objectMap _pendingObject[0]; //TODO:
|
||||
if (objectTypeId(_pendingObject[0]) == kGameObjectHitZone) {
|
||||
hitZone = _vm->_scene->_objectMap->getHitZone(objectIdToIndex(_pendingObject[0]));
|
||||
} else {
|
||||
if ((_pendingVerb == kVerbUse) && (objectIdType(_pendingObject[1]) == kGameObjectHitZone)) {
|
||||
// hitZone = _vm->_scene->_objectMap _pendingObject[1]; //TODO:
|
||||
if ((_pendingVerb == kVerbUse) && (objectTypeId(_pendingObject[1]) == kGameObjectHitZone)) {
|
||||
hitZone = _vm->_scene->_objectMap->getHitZone(objectIdToIndex(_pendingObject[1]));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -842,7 +848,16 @@ void Script::playfieldClick(const Point& mousePoint, bool leftButton) {
|
|||
}
|
||||
|
||||
if (hitZone->getFlags() & kHitZoneProject) {
|
||||
//TODO: do it
|
||||
if (!hitZone->getSpecialPoint(specialPoint)) {
|
||||
error("Script::playfieldClick SpecialPoint not found");
|
||||
}
|
||||
|
||||
// tiled stuff
|
||||
if (_vm->_scene->getFlags() & kSceneFlagISO) {
|
||||
//todo: it
|
||||
} else {
|
||||
pickLocation.fromScreenPoint(specialPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -856,7 +871,7 @@ void Script::playfieldClick(const Point& mousePoint, bool leftButton) {
|
|||
break;
|
||||
|
||||
case kVerbLookAt:
|
||||
if (objectIdType(_pendingObject[0]) != kGameObjectActor ) {
|
||||
if (objectTypeId(_pendingObject[0]) != kGameObjectActor ) {
|
||||
_vm->_actor->actorWalkTo(ID_PROTAG, pickLocation);
|
||||
} else {
|
||||
doVerb();
|
||||
|
@ -879,8 +894,8 @@ void Script::whichObject(const Point& mousePoint) {
|
|||
uint16 newObjectId;
|
||||
ActorData *actor;
|
||||
Location pickLocation;
|
||||
int hitZoneId;
|
||||
HitZone * hitZone;
|
||||
int hitZoneIndex;
|
||||
const HitZone * hitZone;
|
||||
|
||||
objectId = ID_NOTHING;
|
||||
objectFlags = 0;
|
||||
|
@ -892,7 +907,7 @@ void Script::whichObject(const Point& mousePoint) {
|
|||
newObjectId = _vm->_actor->testHit(mousePoint);
|
||||
|
||||
if (newObjectId != ID_NOTHING) {
|
||||
if (objectIdType(newObjectId) == kGameObjectObject) {
|
||||
if (objectTypeId(newObjectId) == kGameObjectObject) {
|
||||
objectId = newObjectId;
|
||||
objectFlags = 0;
|
||||
newRightButtonVerb = kVerbLookAt;
|
||||
|
@ -919,10 +934,6 @@ void Script::whichObject(const Point& mousePoint) {
|
|||
}
|
||||
|
||||
if (newObjectId == ID_NOTHING) {
|
||||
/* struct HitZone far *newZone = NULL;
|
||||
UWORD zone;*/
|
||||
|
||||
|
||||
if (_vm->_scene->getFlags() & kSceneFlagISO) {
|
||||
//todo: it
|
||||
} else {
|
||||
|
@ -931,11 +942,11 @@ void Script::whichObject(const Point& mousePoint) {
|
|||
pickLocation.z = 0;
|
||||
}
|
||||
|
||||
hitZoneId = _vm->_scene->_objectMap->hitTest(mousePoint);
|
||||
hitZoneIndex = _vm->_scene->_objectMap->hitTest(mousePoint);
|
||||
|
||||
if ((hitZoneId != -1) && 0) { //TODO: do it
|
||||
//hitZone = _vm->_scene->_objectMap->getZone(hitZoneId);
|
||||
//objectId = hitZone->objectId;
|
||||
if ((hitZoneIndex != -1)) {
|
||||
hitZone = _vm->_scene->_objectMap->getHitZone(hitZoneIndex);
|
||||
objectId = hitZone->getHitZoneId();
|
||||
objectFlags = 0;
|
||||
newRightButtonVerb = hitZone->getRightButtonVerb() & 0x7f;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue