Character switching, entity ID capture, and UI fixes
- Switch Character now works: send LeaveGame, then re-send LoginRequest with cached token to trigger account screen - Capture player entity ID from LoadInstance packet to identify own messages and enable LeaveGame - Fix MsgPackValue Decodable to handle MessagePack timestamp extension types (Date) - ListBox uses button-based rows instead of Picker (server expects click events, not selection changes) - Dynamic nav title per buffer tab (Kirandur Global Chat, Map Chat, etc.) - App icon on login screen via separate Logo image asset - AsyncStream buffering set to unbounded
This commit is contained in:
parent
f9c751a993
commit
26182ec8d0
10 changed files with 84 additions and 31 deletions
BIN
KDChat/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
KDChat/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
"value" : "dark"
|
"value" : "dark"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
@ -23,6 +25,7 @@
|
||||||
"value" : "tinted"
|
"value" : "tinted"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|
|
||||||
12
KDChat/Assets.xcassets/Logo.imageset/Contents.json
vendored
Normal file
12
KDChat/Assets.xcassets/Logo.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Logo.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
KDChat/Assets.xcassets/Logo.imageset/Logo.png
vendored
Normal file
BIN
KDChat/Assets.xcassets/Logo.imageset/Logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
|
|
@ -156,7 +156,7 @@ actor LNLClient {
|
||||||
let ackSize = (ws - 1) / 8 + 2
|
let ackSize = (ws - 1) / 8 + 2
|
||||||
self.ackBitfield = Array(repeating: 0, count: ackSize)
|
self.ackBitfield = Array(repeating: 0, count: ackSize)
|
||||||
|
|
||||||
let (stream, continuation) = AsyncStream.makeStream(of: Event.self)
|
let (stream, continuation) = AsyncStream.makeStream(of: Event.self, bufferingPolicy: .unbounded)
|
||||||
self.events = stream
|
self.events = stream
|
||||||
self.eventContinuation = continuation
|
self.eventContinuation = continuation
|
||||||
}
|
}
|
||||||
|
|
@ -450,7 +450,8 @@ actor LNLClient {
|
||||||
}
|
}
|
||||||
incomingFragments.removeValue(forKey: fragmentId)
|
incomingFragments.removeValue(forKey: fragmentId)
|
||||||
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
|
lnlLog.info("Reassembled fragmented packet: \(fragment.totalParts) parts, \(assembled.count) bytes")
|
||||||
eventContinuation.yield(.received(assembled))
|
let yieldResult = eventContinuation.yield(.received(assembled))
|
||||||
|
lnlLog.info("Yield result for reassembled packet: \(String(describing: yieldResult))")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])
|
let userData = Data(data[data.startIndex + LNL.channeledHeaderSize ..< data.endIndex])
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ extension MsgPackValue: Decodable {
|
||||||
if let v = try? c.decode(Double.self) { self = .double(v); return }
|
if let v = try? c.decode(Double.self) { self = .double(v); return }
|
||||||
if let v = try? c.decode(Float.self) { self = .float(v); return }
|
if let v = try? c.decode(Float.self) { self = .float(v); return }
|
||||||
if let v = try? c.decode(Data.self) { self = .binary(v); return }
|
if let v = try? c.decode(Data.self) { self = .binary(v); return }
|
||||||
|
// MessagePack ext types (e.g. timestamps) — decode as Date, store as string
|
||||||
|
if let v = try? c.decode(Date.self) { self = .string(v.description); return }
|
||||||
|
|
||||||
throw DecodingError.dataCorrupted(
|
throw DecodingError.dataCorrupted(
|
||||||
DecodingError.Context(codingPath: decoder.codingPath,
|
DecodingError.Context(codingPath: decoder.codingPath,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import MessagePack
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
@ -120,6 +121,7 @@ final class ConnectionManager {
|
||||||
self.handleConnected()
|
self.handleConnected()
|
||||||
}
|
}
|
||||||
case .received(let data):
|
case .received(let data):
|
||||||
|
log.debug("Event: received \(data.count) bytes, method=\(data.count >= 4 ? String(data.withUnsafeBytes { Int32(littleEndian: $0.loadUnaligned(as: Int32.self)) }) : "?")")
|
||||||
self.handlePacket(data)
|
self.handlePacket(data)
|
||||||
case .disconnected(let reason):
|
case .disconnected(let reason):
|
||||||
self.handleDisconnect(reason: reason)
|
self.handleDisconnect(reason: reason)
|
||||||
|
|
@ -250,11 +252,16 @@ final class ConnectionManager {
|
||||||
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
|
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
|
||||||
handleAddMessage(message)
|
handleAddMessage(message)
|
||||||
case .loadInstance:
|
case .loadInstance:
|
||||||
if let value = PacketDecoder.decodeDynamic(from: payload),
|
do {
|
||||||
let entityId = value[1]?.stringValue {
|
let value = try MessagePackDecoder().decode(MsgPackValue.self, from: payload)
|
||||||
|
if let entityId = value[1]?.stringValue {
|
||||||
localSenderId = entityId
|
localSenderId = entityId
|
||||||
|
currentCharacterId = entityId
|
||||||
log.info("Local entity ID: \(entityId)")
|
log.info("Local entity ID: \(entityId)")
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
log.error("LoadInstance decode failed: \(error)")
|
||||||
|
}
|
||||||
case .requestGameplay:
|
case .requestGameplay:
|
||||||
handleRequestGameplay()
|
handleRequestGameplay()
|
||||||
case .leaveGameUpdate:
|
case .leaveGameUpdate:
|
||||||
|
|
@ -343,6 +350,12 @@ final class ConnectionManager {
|
||||||
private func handleAddMessage(_ message: ChatMessage) {
|
private func handleAddMessage(_ message: ChatMessage) {
|
||||||
messages[message.buffer, default: []].append(message)
|
messages[message.buffer, default: []].append(message)
|
||||||
|
|
||||||
|
// Learn our own entity ID from echoed messages
|
||||||
|
if message.senderIsRecipient, let id = message.senderId {
|
||||||
|
if localSenderId == nil { localSenderId = id }
|
||||||
|
if currentCharacterId == nil { currentCharacterId = id }
|
||||||
|
}
|
||||||
|
|
||||||
messageFeedbackTrigger += 1
|
messageFeedbackTrigger += 1
|
||||||
lastAnnouncement = "\(message.senderName): \(message.content)"
|
lastAnnouncement = "\(message.senderName): \(message.content)"
|
||||||
playMessageSound(buffer: message.buffer)
|
playMessageSound(buffer: message.buffer)
|
||||||
|
|
@ -382,6 +395,7 @@ final class ConnectionManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Game State Handlers
|
// MARK: - Game State Handlers
|
||||||
|
|
||||||
private func handleRequestGameplay() {
|
private func handleRequestGameplay() {
|
||||||
|
|
@ -392,9 +406,13 @@ final class ConnectionManager {
|
||||||
|
|
||||||
private func handleLeaveGameUpdate(_ response: GenericResponse) {
|
private func handleLeaveGameUpdate(_ response: GenericResponse) {
|
||||||
if response.success {
|
if response.success {
|
||||||
// Clear chat state, wait for new RemoteForms
|
// Clear chat state, re-login to get account screen
|
||||||
resetState()
|
resetState()
|
||||||
|
isLoggedIn = false
|
||||||
screen = .forms
|
screen = .forms
|
||||||
|
if let token = cachedToken {
|
||||||
|
loginWithToken(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Kirandur Chat")
|
.navigationTitle(titleForBuffer(selectedBuffer))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
|
@ -66,6 +66,16 @@ struct ChatView: View {
|
||||||
return buffer.canSend
|
return buffer.canSend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func titleForBuffer(_ name: String) -> String {
|
||||||
|
switch name {
|
||||||
|
case "global": "Kirandur Global Chat"
|
||||||
|
case "map": "Kirandur Map Chat"
|
||||||
|
case "debug": "Kirandur Debug Output"
|
||||||
|
case "direct-messages": "Kirandur DMs"
|
||||||
|
default: "Kirandur \(name.capitalized)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func iconForBuffer(_ name: String) -> String {
|
private func iconForBuffer(_ name: String) -> String {
|
||||||
switch name {
|
switch name {
|
||||||
case "global": "globe"
|
case "global": "globe"
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,30 @@ struct ListBoxContent: View {
|
||||||
@Environment(ConnectionManager.self) private var manager
|
@Environment(ConnectionManager.self) private var manager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(widget.label, selection: Bindable(widget).selectedIndex) {
|
if !widget.label.isEmpty {
|
||||||
|
Text(widget.label)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
ForEach(widget.items.indices, id: \.self) { index in
|
ForEach(widget.items.indices, id: \.self) { index in
|
||||||
Text(widget.items[index]).tag(index)
|
Button {
|
||||||
}
|
widget.selectedIndex = index
|
||||||
}
|
|
||||||
.disabled(!widget.enabled)
|
|
||||||
.onChange(of: widget.selectedIndex) {
|
|
||||||
sendSelection()
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if widget.items.count == 1 {
|
|
||||||
sendSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendSelection() {
|
|
||||||
manager.sendFormEvent(
|
manager.sendFormEvent(
|
||||||
formId: formId, widgetId: widget.id,
|
formId: formId, widgetId: widget.id,
|
||||||
eventType: .listBoxItemClicked,
|
eventType: .listBoxItemClicked,
|
||||||
value: .uint(UInt64(widget.selectedIndex))
|
value: .uint(UInt64(index))
|
||||||
)
|
)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(widget.items[index])
|
||||||
|
Spacer()
|
||||||
|
if index == widget.selectedIndex {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityAddTraits(index == widget.selectedIndex ? .isSelected : [])
|
||||||
|
.disabled(!widget.enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,13 @@ struct LoginView: View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
Text("Enter your email and password to log in.")
|
Image(.logo)
|
||||||
.font(.subheadline)
|
.resizable()
|
||||||
.foregroundStyle(.secondary)
|
.scaledToFit()
|
||||||
|
.frame(maxHeight: 120)
|
||||||
|
.clipShape(.rect(cornerRadius: 24))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Credentials") {
|
Section("Credentials") {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue