Add character creation screen with race/class/appearance customization

Implements a full character creation UI integrated into the existing flow.
In single-player mode, auth screen now goes to character creation before
entering the world. In online mode, a "Create Character" button on the
character selection screen sends CMSG_CHAR_CREATE to the server. Includes
WoW 3.3.5a race/class combo validation and appearance range limits.
This commit is contained in:
Kelsi 2026-02-05 14:13:48 -08:00
parent 129bbac9b3
commit 0605d1522d
16 changed files with 611 additions and 30 deletions

View file

@ -3,6 +3,54 @@
namespace wowee {
namespace game {
bool isValidRaceClassCombo(Race race, Class cls) {
// WoW 3.3.5a valid race/class combinations
switch (race) {
case Race::HUMAN:
return cls == Class::WARRIOR || cls == Class::PALADIN || cls == Class::ROGUE ||
cls == Class::PRIEST || cls == Class::MAGE || cls == Class::WARLOCK ||
cls == Class::DEATH_KNIGHT;
case Race::ORC:
return cls == Class::WARRIOR || cls == Class::HUNTER || cls == Class::ROGUE ||
cls == Class::SHAMAN || cls == Class::WARLOCK || cls == Class::DEATH_KNIGHT;
case Race::DWARF:
return cls == Class::WARRIOR || cls == Class::PALADIN || cls == Class::HUNTER ||
cls == Class::ROGUE || cls == Class::PRIEST || cls == Class::DEATH_KNIGHT;
case Race::NIGHT_ELF:
return cls == Class::WARRIOR || cls == Class::HUNTER || cls == Class::ROGUE ||
cls == Class::PRIEST || cls == Class::DRUID || cls == Class::DEATH_KNIGHT;
case Race::UNDEAD:
return cls == Class::WARRIOR || cls == Class::ROGUE || cls == Class::PRIEST ||
cls == Class::MAGE || cls == Class::WARLOCK || cls == Class::DEATH_KNIGHT;
case Race::TAUREN:
return cls == Class::WARRIOR || cls == Class::HUNTER || cls == Class::DRUID ||
cls == Class::SHAMAN || cls == Class::DEATH_KNIGHT;
case Race::GNOME:
return cls == Class::WARRIOR || cls == Class::ROGUE || cls == Class::MAGE ||
cls == Class::WARLOCK || cls == Class::DEATH_KNIGHT;
case Race::TROLL:
return cls == Class::WARRIOR || cls == Class::HUNTER || cls == Class::ROGUE ||
cls == Class::PRIEST || cls == Class::SHAMAN || cls == Class::MAGE ||
cls == Class::DEATH_KNIGHT;
case Race::BLOOD_ELF:
return cls == Class::PALADIN || cls == Class::HUNTER || cls == Class::ROGUE ||
cls == Class::PRIEST || cls == Class::MAGE || cls == Class::WARLOCK ||
cls == Class::DEATH_KNIGHT;
case Race::DRAENEI:
return cls == Class::WARRIOR || cls == Class::PALADIN || cls == Class::HUNTER ||
cls == Class::PRIEST || cls == Class::SHAMAN || cls == Class::MAGE ||
cls == Class::DEATH_KNIGHT;
default:
return false;
}
}
uint8_t getMaxSkin(Race /*race*/, Gender /*gender*/) { return 9; }
uint8_t getMaxFace(Race /*race*/, Gender /*gender*/) { return 9; }
uint8_t getMaxHairStyle(Race /*race*/, Gender /*gender*/) { return 11; }
uint8_t getMaxHairColor(Race /*race*/, Gender /*gender*/) { return 9; }
uint8_t getMaxFacialFeature(Race /*race*/, Gender /*gender*/) { return 8; }
const char* getRaceName(Race race) {
switch (race) {
case Race::HUMAN: return "Human";

View file

@ -501,6 +501,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
case Opcode::SMSG_CHAR_CREATE:
handleCharCreateResponse(packet);
break;
case Opcode::SMSG_CHAR_ENUM:
if (state == WorldState::CHAR_LIST_REQUESTED) {
handleCharEnum(packet);
@ -822,6 +826,81 @@ void GameHandler::handleCharEnum(network::Packet& packet) {
LOG_INFO("Ready to select character");
}
void GameHandler::createCharacter(const CharCreateData& data) {
if (singlePlayerMode_) {
// Create character locally
Character ch;
ch.guid = 0x0000000100000001ULL + characters.size();
ch.name = data.name;
ch.race = data.race;
ch.characterClass = data.characterClass;
ch.gender = data.gender;
ch.level = 1;
ch.appearanceBytes = (static_cast<uint32_t>(data.skin)) |
(static_cast<uint32_t>(data.face) << 8) |
(static_cast<uint32_t>(data.hairStyle) << 16) |
(static_cast<uint32_t>(data.hairColor) << 24);
ch.facialFeatures = data.facialHair;
ch.zoneId = 12; // Elwynn Forest default
ch.mapId = 0;
ch.x = -8949.95f;
ch.y = -132.493f;
ch.z = 83.5312f;
ch.guildId = 0;
ch.flags = 0;
ch.pet = {};
characters.push_back(ch);
LOG_INFO("Single-player character created: ", ch.name);
if (charCreateCallback_) {
charCreateCallback_(true, "Character created!");
}
return;
}
// Online mode: send packet to server
if (!socket) {
LOG_WARNING("Cannot create character: not connected");
if (charCreateCallback_) {
charCreateCallback_(false, "Not connected to server");
}
return;
}
auto packet = CharCreatePacket::build(data);
socket->send(packet);
LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name);
}
void GameHandler::handleCharCreateResponse(network::Packet& packet) {
CharCreateResponseData data;
if (!CharCreateResponseParser::parse(packet, data)) {
LOG_ERROR("Failed to parse SMSG_CHAR_CREATE");
return;
}
if (data.result == CharCreateResult::SUCCESS) {
LOG_INFO("Character created successfully");
requestCharacterList();
if (charCreateCallback_) {
charCreateCallback_(true, "Character created!");
}
} else {
std::string msg;
switch (data.result) {
case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break;
case CharCreateResult::DISABLED: msg = "Character creation disabled"; break;
case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break;
case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break;
default: msg = "Character creation failed"; break;
}
LOG_WARNING("Character creation failed: ", msg);
if (charCreateCallback_) {
charCreateCallback_(false, msg);
}
}
}
void GameHandler::selectCharacter(uint64_t characterGuid) {
if (state != WorldState::CHAR_LIST_RECEIVED) {
LOG_WARNING("Cannot select character in state: ", (int)state);
@ -2489,6 +2568,15 @@ void GameHandler::simulateXpGain(uint64_t victimGuid, uint32_t totalXp) {
handleXpGain(packet);
}
void GameHandler::simulateMotd(const std::vector<std::string>& lines) {
network::Packet packet(static_cast<uint16_t>(Opcode::SMSG_MOTD));
packet.writeUInt32(static_cast<uint32_t>(lines.size()));
for (const auto& line : lines) {
packet.writeString(line);
}
handleMotd(packet);
}
void GameHandler::addMoneyCopper(uint32_t amount) {
if (amount == 0) return;
playerMoneyCopper_ += amount;

View file

@ -202,6 +202,35 @@ const char* getAuthResultString(AuthResult result) {
}
}
// ============================================================
// Character Creation
// ============================================================
network::Packet CharCreatePacket::build(const CharCreateData& data) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CHAR_CREATE));
packet.writeString(data.name); // null-terminated name
packet.writeUInt8(static_cast<uint8_t>(data.race));
packet.writeUInt8(static_cast<uint8_t>(data.characterClass));
packet.writeUInt8(static_cast<uint8_t>(data.gender));
packet.writeUInt8(data.skin);
packet.writeUInt8(data.face);
packet.writeUInt8(data.hairStyle);
packet.writeUInt8(data.hairColor);
packet.writeUInt8(data.facialHair);
packet.writeUInt8(0); // outfitId, always 0
LOG_DEBUG("Built CMSG_CHAR_CREATE: name=", data.name);
return packet;
}
bool CharCreateResponseParser::parse(network::Packet& packet, CharCreateResponseData& data) {
data.result = static_cast<CharCreateResult>(packet.readUInt8());
LOG_INFO("SMSG_CHAR_CREATE result: ", static_cast<int>(data.result));
return true;
}
network::Packet CharEnumPacket::build() {
// CMSG_CHAR_ENUM has no body - just the opcode
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_CHAR_ENUM));