# 덕링 인게임 공지 연동 가이드 (Duckring Game SDK)

> **이 문서를 읽는 AI 에이전트에게**
> 당신이 할 일은 게임 클라이언트(Unity 등)에서 덕링 홈페이지에 작성된 공지/이벤트를
> 게임 화면에 띄우는 것입니다. 서버 작업은 **전부 끝나 있습니다** — 새 API를 만들거나
> 덕링 쪽 코드를 수정할 필요가 없습니다.
> 순서: ① 아래 `2. 두 가지 연동 방식`에서 하나 고르기 → ② `4. API 레퍼런스` 또는
> `5. 웹뷰 URL 레퍼런스`의 엔드포인트를 그대로 호출 → ③ `7. Unity 구현 코드`를
> 프로젝트에 복사 → ④ `DuckringConfig.GameSlug` 를 게임 슬러그로 바꾸기 → ⑤ `9. 체크리스트` 확인.
> 인증 키는 **필요 없습니다**. 전부 공개 읽기 전용 API입니다.

- **최종 갱신**: 2026-10-16
- **Base URL**: `https://www.duckring.com`
- **인증**: 없음 (공개 읽기 전용)
- **게임 슬러그 예시**: `NYMPH` (TITAN'S CELL: NYMPH)

---

## 1. 무엇을 하는 건가

네이버 플러그 / 네이버 라운지 / 스토브의 인게임 공지와 같은 구조입니다.

```
[운영자]  덕링 홈페이지 /admin/community 에서 공지·이벤트 작성 (이미지 첨부 가능)
             │
             ▼
[덕링 서버]  Supabase 저장 → 공개 API + 웹뷰 전용 페이지로 노출 (CDN 60초 캐시)
             │
   ┌─────────┴──────────┐
   ▼                    ▼
[방식 A]              [방식 B]
JSON API 호출         웹뷰로 URL 로드
→ 게임 자체 UI로       → 덕링이 만든 모바일 화면을
   팝업/배너 렌더         그대로 전체화면 오버레이
```

핵심: **게임을 다시 빌드하지 않고 공지를 바꿀 수 있습니다.** 운영자가 홈페이지에서
글을 쓰면 최대 60초 뒤 게임에 반영됩니다.

---

## 2. 두 가지 연동 방식

| | 방식 A — JSON API + 게임 UI | 방식 B — 웹뷰 오버레이 |
|---|---|---|
| 구현 난이도 | 쉬움 (UnityWebRequest만) | 보통 (웹뷰 플러그인 필요) |
| 추가 의존성 | 없음 | `gree/unity-webview`(무료) 또는 Vuplex(유료) |
| 디자인 | 게임 아트에 100% 맞출 수 있음 | 덕링 제공 디자인 (다크/라이트, 색만 고정) |
| 긴 본문·표·이미지 여러 장 | 직접 구현해야 함 | 그냥 됨 |
| 앱 심사 리스크 | 가장 낮음 | 낮음 (웹뷰는 흔한 패턴) |
| 추천 용도 | **게임 실행 시 공지 팝업, 로비 배너** | **공지 목록/상세 전체 보기 버튼** |

> **권장 조합**: 실행 시 팝업 1건은 **방식 A**(`/popup`)로 게임 UI에 띄우고,
> 팝업 안의 "자세히 보기" / 로비의 "공지" 버튼은 **방식 B** 웹뷰로 엽니다.
> 네이버 플러그도 이 조합입니다.

---

## 3. 운영자가 먼저 해야 하는 일 (코드 아님)

인게임 공지는 **홈페이지에 글이 있어야** 나옵니다.

1. `https://www.duckring.com/admin/community` 접속 (관리자 계정)
2. 게임 선택 → 글 작성
3. 카테고리: `공지` / `이벤트` / `가이드` / `공략` / `자유게시판`
4. 이미지 첨부 → 응답의 `images[]`, `thumbnail` 로 내려갑니다
5. **상단 이미지 / 하단 이미지** 지정 → 본문(`content_html`) 맨 위·맨 아래에
   `<figure data-duckring-slot="header|footer">` 로 자동 삽입됩니다.
   첨부 이미지가 없으면 **상단 이미지가 `thumbnail`·`popup.image_url` 로도 쓰입니다**
   (목록 썸네일/배너형 팝업이 자동으로 채워짐)
6. "고정"(`is_pinned`) 체크 시 목록 최상단
7. 게시 상태(`is_published`)가 꺼져 있으면 API/웹뷰에 **안 나옵니다**

> 2026-10-16 현재 공지 테이블이 비어 있습니다. 글을 하나도 안 쓰면 API는
> 정상적으로 `items: []`, `popup: null` 을 반환합니다 (에러가 아님).

---

## 4. API 레퍼런스 (방식 A)

모든 응답은 `application/json; charset=utf-8`, CORS 전면 허용(`*`),
CDN 캐시 `s-maxage=60`. **레이트리밋 없음**이지만 게임 실행 시 1회 호출을 권장합니다.

### 4-1. 공지 목록

```
GET /api/v1/games/{slug}/notices
```

| 쿼리 | 기본값 | 설명 |
|---|---|---|
| `category` | `notice,event` | `event` `notice` `free` `guide` `strategy` 중 쉼표 구분. `all` 가능 |
| `limit` | `20` | 1~50 |
| `page` | `1` | 1부터 |
| `search` | — | 제목·본문 부분검색 |
| `include` | — | `content` 를 주면 `content_text` / `content_html` 까지 포함 |

**예시**

```
https://www.duckring.com/api/v1/games/NYMPH/notices
https://www.duckring.com/api/v1/games/NYMPH/notices?category=all&limit=10
https://www.duckring.com/api/v1/games/NYMPH/notices?category=notice&include=content
```

**응답 200**

```json
{
  "game": {
    "id": "eb6f8b0a-4166-4f41-9116-bfd703d6db9e",
    "slug": "NYMPH",
    "title": "TITAN'S CELL: NYMPH",
    "icon_url": "https://.../icon.png"
  },
  "categories": [
    { "key": "notice", "label": "공지" },
    { "key": "event", "label": "이벤트" }
  ],
  "items": [
    {
      "id": "5c99317c-ed5b-435a-a041-0fbc8b740b54",
      "category": "notice",
      "category_label": "공지",
      "title": "서버 점검 안내",
      "excerpt": "10월 20일 02:00~06:00 점검이 진행됩니다. • 보상: 다이아 100개",
      "images": ["https://.../banner.png"],
      "thumbnail": "https://.../banner.png",
      "is_pinned": true,
      "author_name": "운영팀",
      "view_count": 0,
      "created_at": "2026-10-16T09:11:17.687678+00:00",
      "updated_at": "2026-10-16T09:11:17.687678+00:00",
      "web_url": "https://www.duckring.com/games/NYMPH/community/5c99317c-...",
      "embed_url": "https://www.duckring.com/embed/games/NYMPH/community/5c99317c-...",
      "content_text": "10월 20일 02:00~06:00 점검이 진행됩니다.\n• 보상: 다이아 100개",
      "content_html": "<p>10월 20일 02:00~06:00 점검이 진행됩니다.</p><ul><li>보상: 다이아 100개</li></ul>"
    }
  ],
  "page": { "page": 1, "limit": 20, "total": 1, "has_more": false },
  "embed_url": "https://www.duckring.com/embed/games/NYMPH/community",
  "generated_at": "2026-10-16T09:11:23.395Z"
}
```

**필드 의미**

| 필드 | 설명 |
|---|---|
| `excerpt` | HTML 태그를 벗긴 200자 요약. 목록 UI에 그대로 쓰면 됩니다 |
| `content_text` | 본문 전문 평문. `\n` 줄바꿈, 리스트는 `• ` 로 변환됨. **TextMeshPro 등 게임 UI용** |
| `content_html` | 정화된 HTML(`<script>` `<iframe>` `on*=` 제거). 웹뷰·리치텍스트용. 운영자가 지정한 상단/하단 이미지가 `<figure data-duckring-slot="header|footer">` 로 포함됩니다 |
| `images[]` | 첨부 이미지 URL 전체 |
| `thumbnail` | `images[0]`, 없으면 본문 상단 이미지, 둘 다 없으면 `null` |
| `is_pinned` | 고정 글. 목록은 고정 → 최신순으로 정렬돼 내려옵니다 |
| `embed_url` | 이 글을 웹뷰로 열 때 쓰는 URL (방식 B와 연결) |
| `web_url` | 외부 브라우저로 열 때 쓰는 URL |

### 4-2. 공지 상세

```
GET /api/v1/games/{slug}/notices/{postId}
```

목록과 달리 `content_text` / `content_html` 이 **항상** 포함됩니다.

```json
{ "game": { "...": "" }, "item": { "...": "목록 item 과 동일한 구조" }, "generated_at": "..." }
```

### 4-3. 실행 시 팝업 1건

```
GET /api/v1/games/{slug}/popup
```

게임 실행 직후 띄울 공지 **한 건**만 골라서 줍니다.
선택 규칙: 기간이 유효한 활성 이벤트 → 없으면 이벤트 글 → 없으면 공지 글.

```json
{
  "game": { "...": "" },
  "popup": {
    "id": "c921cc6f-c361-4039-a22e-d00dc13456d6",
    "source": "event",
    "title": "할로윈 이벤트",
    "body_text": "할로윈 한정 이벤트가 시작됩니다!",
    "image_url": "https://.../halloween.png",
    "link_url": "https://www.duckring.com/games/NYMPH/community",
    "embed_url": "https://www.duckring.com/embed/games/NYMPH/community?category=event",
    "start_date": "2026-10-16T00:00:00+00:00",
    "end_date": "2026-10-31T23:59:59+00:00"
  },
  "generated_at": "2026-10-16T09:11:23.395Z"
}
```

- 띄울 게 없으면 `"popup": null` (HTTP 200, 에러 아님)
- **"오늘 하루 보지 않기"**: `popup.id` 를 `PlayerPrefs` 에 날짜와 함께 저장해
  같은 `id` + 같은 날짜면 스킵하세요. `id` 가 바뀌면 새 공지이므로 다시 띄웁니다.
- `image_url` 이 있으면 이미지만 꽉 채운 배너형 팝업으로, 없으면 텍스트 팝업으로

### 4-4. 에러 응답

```json
{ "error": { "code": "game_not_found", "message": "게임을 찾을 수 없습니다: NOPE" } }
```

| 코드 | HTTP | 원인 |
|---|---|---|
| `game_not_found` | 404 | 슬러그가 틀렸거나 게임이 미게시 상태 |
| `notice_not_found` | 404 | 글이 삭제/비공개 처리됨 |
| `internal_error` | 500 | 서버 오류 — 게임은 공지 없이 정상 진행해야 함 |

> **중요**: 공지 로딩 실패가 게임 진입을 막아선 안 됩니다. 타임아웃 5초,
> 실패 시 조용히 스킵하도록 구현하세요.

---

## 5. 웹뷰 URL 레퍼런스 (방식 B)

```
GET /embed/games/{slug}/community            ← 목록
GET /embed/games/{slug}/community/{postId}    ← 상세
```

인게임 웹뷰 전용 모바일 화면입니다. 홈페이지 레이아웃(jQuery/부트스트랩 ~1MB)을
타지 않는 **독립 HTML 8KB**, 외부 리소스 요청 0건이라 웹뷰에서 즉시 열립니다.

| 쿼리 | 기본값 | 설명 |
|---|---|---|
| `category` | `notice` | 처음 열릴 탭 (`event` `notice` `free` `guide` `strategy`) |
| `theme` | `dark` | `dark` 또는 `light` |
| `chrome` | `1` | `0` 이면 상단 제목바를 숨김 — 게임이 자체 헤더를 그릴 때 |
| `close` | `0` | `1` 이면 상단에 닫기(×) 버튼 표시 (브리지로 `close` 전송) |
| `page` / `limit` | `1` / `20` | 페이지네이션 |

**예시**

```
https://www.duckring.com/embed/games/NYMPH/community?close=1
https://www.duckring.com/embed/games/NYMPH/community?category=event&theme=light
https://www.duckring.com/embed/games/NYMPH/community?chrome=0        ← 게임이 헤더를 직접 그릴 때
```

특징
- `viewport-fit=cover` + `env(safe-area-inset-*)` → 노치/홈바 대응 완료
- 카테고리 탭, 고정글 배지, 썸네일, 페이지 이동 포함
- 맨 아래 "브라우저에서 커뮤니티 전체 보기" → 브리지로 `openExternal` 전송
- `frame-ancestors *` 로 **iframe 임베드 허용** (Unity WebGL 빌드에서도 사용 가능).
  홈페이지 나머지 경로는 여전히 `X-Frame-Options: DENY` 입니다.
- `noindex` — 검색엔진에 노출되지 않습니다

---

## 6. 웹뷰 → 게임 브리지 프로토콜

웹뷰 안에서 닫기 버튼이나 외부 링크를 누르면, 페이지가 게임 쪽으로 메시지를 보냅니다.
전달 경로를 순서대로 시도하고, **JS 브리지 객체가 하나도 없을 때만**
`unity:` 커스텀 스킴으로 떨어집니다.

메시지 형식 (JSON 문자열):

```json
{ "source": "duckring-embed", "action": "close", "payload": null }
```

| action | payload | 게임이 해야 할 일 |
|---|---|---|
| `ready` | `{ "height": 1234 }` | (선택) 로딩 스피너 끄기, 높이 맞추기 |
| `close` | `null` | 웹뷰를 닫고 게임으로 복귀 |
| `openExternal` | `{ "url": "https://..." }` | OS 기본 브라우저로 열기 (`Application.OpenURL`) |

전달 경로 (위에서부터 시도)

1. `window.DuckringUnity.postMessage(msg)` — 게임이 이 이름으로 객체를 주입한 경우
2. `window.webkit.messageHandlers.duckring.postMessage(msg)` — iOS WKWebView
3. `window.DuckringAndroid.onMessage(msg)` — Android `addJavascriptInterface(obj, "DuckringAndroid")`
4. `window.parent.postMessage(msg, "*")` — iframe / Unity WebGL
5. `unity:<encodeURIComponent(msg)>` — **gree/unity-webview 기본 방식**.
   숨은 iframe으로 보내므로 일반 브라우저에서도 페이지가 깨지지 않습니다.

> 5번을 쓸 때는 C# 쪽에서 `WWW.UnEscapeURL(...)` 로 디코딩한 뒤 JSON 파싱하세요.

---

## 7. Unity 구현 코드

그대로 복사해서 쓸 수 있습니다. `DuckringConfig.GameSlug` 만 게임 슬러그로 바꾸세요.

### 7-1. `Assets/Duckring/DuckringNotices.cs` — API 클라이언트 (방식 A)

```csharp
// 덕링 인게임 공지 API 클라이언트
// 의존성 없음 (UnityWebRequest + JsonUtility만 사용)
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

namespace Duckring
{
    public static class DuckringConfig
    {
        public const string BaseUrl  = "https://www.duckring.com";
        public const string GameSlug = "NYMPH";          // ← 게임 슬러그로 변경
        public const int    TimeoutSeconds = 5;          // 공지 로딩이 게임 진입을 막지 않도록 짧게
    }

    // ---------- 응답 모델 ----------
    [Serializable] public class NoticeGame
    {
        public string id, slug, title, icon_url;
    }

    [Serializable] public class NoticeItem
    {
        public string id, category, category_label, title, excerpt;
        public string[] images;
        public string thumbnail;
        public bool is_pinned;
        public string author_name;
        public int view_count;
        public string created_at, updated_at, web_url, embed_url;
        public string content_text;   // include=content 또는 상세 조회에서만 채워짐
        public string content_html;
    }

    [Serializable] public class NoticePageInfo
    {
        public int page, limit, total;
        public bool has_more;
    }

    [Serializable] public class NoticeListResponse
    {
        public NoticeGame game;
        public NoticeItem[] items;
        public NoticePageInfo page;
        public string embed_url, generated_at;
    }

    [Serializable] public class NoticeDetailResponse
    {
        public NoticeGame game;
        public NoticeItem item;
    }

    [Serializable] public class NoticePopup
    {
        public string id, source, title, body_text;
        public string image_url, link_url, embed_url;
        public string start_date, end_date;
    }

    [Serializable] public class NoticePopupResponse
    {
        public NoticeGame game;
        public NoticePopup popup;
    }

    // ---------- 호출 ----------
    public static class DuckringNotices
    {
        /// <summary>공지 목록. category 예: "notice,event" / "all"</summary>
        public static IEnumerator FetchList(
            string category, int limit, bool includeContent,
            Action<NoticeListResponse> onSuccess, Action<string> onError = null)
        {
            string url = string.Format(
                "{0}/api/v1/games/{1}/notices?category={2}&limit={3}{4}",
                DuckringConfig.BaseUrl,
                UnityWebRequest.EscapeURL(DuckringConfig.GameSlug),
                UnityWebRequest.EscapeURL(category),
                limit,
                includeContent ? "&include=content" : "");

            yield return Get<NoticeListResponse>(url, onSuccess, onError);
        }

        /// <summary>공지 상세 (본문 전문 포함)</summary>
        public static IEnumerator FetchDetail(
            string postId,
            Action<NoticeDetailResponse> onSuccess, Action<string> onError = null)
        {
            string url = string.Format("{0}/api/v1/games/{1}/notices/{2}",
                DuckringConfig.BaseUrl,
                UnityWebRequest.EscapeURL(DuckringConfig.GameSlug),
                UnityWebRequest.EscapeURL(postId));

            yield return Get<NoticeDetailResponse>(url, onSuccess, onError);
        }

        /// <summary>실행 시 띄울 팝업 1건. 없으면 popup == null</summary>
        public static IEnumerator FetchPopup(
            Action<NoticePopup> onSuccess, Action<string> onError = null)
        {
            string url = string.Format("{0}/api/v1/games/{1}/popup",
                DuckringConfig.BaseUrl,
                UnityWebRequest.EscapeURL(DuckringConfig.GameSlug));

            yield return Get<NoticePopupResponse>(url,
                res =>
                {
                    bool empty = res == null || res.popup == null
                                 || string.IsNullOrEmpty(res.popup.id);
                    onSuccess(empty ? null : res.popup);
                },
                onError);
        }

        /// <summary>이미지 URL → Texture2D (썸네일/배너 로딩용)</summary>
        public static IEnumerator FetchImage(
            string url, Action<Texture2D> onSuccess, Action<string> onError = null)
        {
            if (string.IsNullOrEmpty(url)) { onSuccess(null); yield break; }

            using (var req = UnityWebRequestTexture.GetTexture(url))
            {
                req.timeout = DuckringConfig.TimeoutSeconds;
                yield return req.SendWebRequest();

                if (req.result != UnityWebRequest.Result.Success)
                {
                    if (onError != null) onError(req.error);
                    else Debug.LogWarning("[Duckring] 이미지 로딩 실패: " + req.error);
                    yield break;
                }
                onSuccess(DownloadHandlerTexture.GetContent(req));
            }
        }

        static IEnumerator Get<T>(string url, Action<T> onSuccess, Action<string> onError)
            where T : class
        {
            using (var req = UnityWebRequest.Get(url))
            {
                req.timeout = DuckringConfig.TimeoutSeconds;
                yield return req.SendWebRequest();

                if (req.result != UnityWebRequest.Result.Success)
                {
                    string msg = req.error + " (" + req.responseCode + ")";
                    if (onError != null) onError(msg);
                    else Debug.LogWarning("[Duckring] 공지 조회 실패: " + msg);
                    yield break;
                }

                T parsed = null;
                try { parsed = JsonUtility.FromJson<T>(req.downloadHandler.text); }
                catch (Exception e)
                {
                    if (onError != null) onError("JSON 파싱 실패: " + e.Message);
                    else Debug.LogWarning("[Duckring] JSON 파싱 실패: " + e.Message);
                    yield break;
                }

                onSuccess(parsed);
            }
        }
    }
}
```

### 7-2. `Assets/Duckring/DuckringNoticePopupController.cs` — 실행 시 팝업

```csharp
// 게임 실행 직후 공지 팝업 1건 + "오늘 하루 보지 않기"
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using Duckring;

public class DuckringNoticePopupController : MonoBehaviour
{
    [Header("UI")]
    public GameObject panel;          // 팝업 루트 (비활성 상태로 시작)
    public Text       titleText;
    public Text       bodyText;
    public RawImage   bannerImage;    // 이미지 없는 공지면 자동으로 숨김
    public Button     detailButton;   // "자세히 보기" → 웹뷰
    public Button     closeButton;
    public Toggle     hideTodayToggle;

    NoticePopup current;

    const string PrefKeyId   = "duckring.popup.id";
    const string PrefKeyDate = "duckring.popup.date";

    void Start()
    {
        if (panel != null) panel.SetActive(false);
        if (closeButton  != null) closeButton.onClick.AddListener(Close);
        if (detailButton != null) detailButton.onClick.AddListener(OpenDetail);

        StartCoroutine(Load());
    }

    IEnumerator Load()
    {
        yield return DuckringNotices.FetchPopup(
            popup =>
            {
                if (popup == null) return;                 // 띄울 공지 없음
                if (IsHiddenToday(popup.id)) return;       // 오늘 하루 보지 않기
                current = popup;
                StartCoroutine(Show(popup));
            },
            err => Debug.LogWarning("[Duckring] 팝업 로딩 실패(무시): " + err));
    }

    IEnumerator Show(NoticePopup popup)
    {
        if (titleText != null) titleText.text = popup.title;
        if (bodyText  != null) bodyText.text  = popup.body_text;

        if (bannerImage != null)
        {
            bannerImage.gameObject.SetActive(false);
            if (!string.IsNullOrEmpty(popup.image_url))
            {
                yield return DuckringNotices.FetchImage(popup.image_url, tex =>
                {
                    if (tex == null) return;
                    bannerImage.texture = tex;
                    bannerImage.gameObject.SetActive(true);
                });
            }
        }

        if (panel != null) panel.SetActive(true);
    }

    void OpenDetail()
    {
        if (current == null) return;

        // 방식 B: 웹뷰 오버레이로 열기 (7-3 참고)
        if (DuckringWebViewOverlay.Instance != null)
            DuckringWebViewOverlay.Instance.Open(current.embed_url);
        else
            Application.OpenURL(current.link_url);   // 웹뷰 플러그인이 없으면 외부 브라우저

        Close();
    }

    void Close()
    {
        if (current != null && hideTodayToggle != null && hideTodayToggle.isOn)
            HideToday(current.id);

        if (panel != null) panel.SetActive(false);
    }

    // 같은 공지(id)를 같은 날짜에 다시 띄우지 않는다. 새 공지면 id가 바뀌어 다시 뜬다.
    static bool IsHiddenToday(string id)
    {
        return PlayerPrefs.GetString(PrefKeyId) == id
            && PlayerPrefs.GetString(PrefKeyDate) == Today();
    }

    static void HideToday(string id)
    {
        PlayerPrefs.SetString(PrefKeyId, id);
        PlayerPrefs.SetString(PrefKeyDate, Today());
        PlayerPrefs.Save();
    }

    static string Today()
    {
        return System.DateTime.Now.ToString("yyyy-MM-dd");
    }
}
```

### 7-3. `Assets/Duckring/DuckringWebViewOverlay.cs` — 웹뷰 오버레이 (방식 B)

`gree/unity-webview` (https://github.com/gree/unity-webview) 설치가 전제입니다.
`Assets/Plugins/WebViewObject.prefab` 를 씬에 올리고 아래 컴포넌트에 연결하세요.

```csharp
// 덕링 공지 웹뷰 전체화면 오버레이
// 전제: gree/unity-webview (WebViewObject)
using UnityEngine;
using Duckring;

public class DuckringWebViewOverlay : MonoBehaviour
{
    public static DuckringWebViewOverlay Instance { get; private set; }

    [Tooltip("gree/unity-webview 의 WebViewObject 컴포넌트")]
    public WebViewObject webViewObject;

    [Tooltip("다크/라이트")]
    public bool useDarkTheme = true;

    void Awake()
    {
        Instance = this;
    }

    /// <summary>공지 목록을 전체화면 웹뷰로 연다</summary>
    public void OpenNoticeList(string category = "notice")
    {
        Open(string.Format(
            "{0}/embed/games/{1}/community?category={2}&theme={3}&close=1",
            DuckringConfig.BaseUrl, DuckringConfig.GameSlug,
            category, useDarkTheme ? "dark" : "light"));
    }

    /// <summary>API 응답의 embed_url 을 그대로 넣어도 된다</summary>
    public void Open(string url)
    {
        if (webViewObject == null)
        {
            Application.OpenURL(url);   // 웹뷰가 없으면 외부 브라우저로 폴백
            return;
        }

        webViewObject.Init(
            cb: OnBridgeMessage,            // unity: 스킴 메시지 수신
            err: msg => Debug.LogWarning("[Duckring] 웹뷰 오류: " + msg),
            httpErr: msg => Debug.LogWarning("[Duckring] 웹뷰 HTTP 오류: " + msg),
            started: _ => { },
            ld: _ => { });

        webViewObject.SetMargins(0, 0, 0, 0);   // 전체화면
        webViewObject.SetVisibility(true);
        webViewObject.LoadURL(url);
    }

    public void Close()
    {
        if (webViewObject != null) webViewObject.SetVisibility(false);
    }

    // 웹뷰 → 게임 메시지. 형식: {"source":"duckring-embed","action":"...","payload":{...}}
    void OnBridgeMessage(string raw)
    {
        string json = WWW.UnEscapeURL(raw);

        if (json.Contains("\"action\":\"close\""))
        {
            Close();
            return;
        }

        if (json.Contains("\"action\":\"openExternal\""))
        {
            string url = ExtractUrl(json);
            if (!string.IsNullOrEmpty(url)) Application.OpenURL(url);
            return;
        }

        // "ready" — 로딩 스피너를 끄는 등의 처리
    }

    static string ExtractUrl(string json)
    {
        const string key = "\"url\":\"";
        int i = json.IndexOf(key);
        if (i < 0) return null;
        i += key.Length;
        int j = json.IndexOf('"', i);
        return j < 0 ? null : json.Substring(i, j - i).Replace("\\/", "/");
    }
}
```

> **메시지 파싱을 더 엄격하게** 하려면 `[Serializable] class BridgeMessage { public string source, action; public BridgePayload payload; }`
> 를 만들어 `JsonUtility.FromJson` 으로 파싱하세요. 위 코드는 의존성 없이 돌아가도록
> 문자열 검사로 처리했습니다.

### 7-4. 로비 "공지" 버튼 연결

```csharp
// 로비 버튼 OnClick 에 연결
public void OnNoticeButtonClicked()
{
    DuckringWebViewOverlay.Instance.OpenNoticeList("notice");
}
```

---

## 8. 계정 연동 (선택)

덕링은 통합 로그인(SSO) 백본입니다. 게임 계정과 덕링 계정을 묶으려면
`POST /api/game-accounts/connect` 를 사용하세요 — 별도 문서:
`https://www.duckring.com/developers/game-sdk.md` 와 같은 위치의
`docs/GAME_ACCOUNT_LINK_API.md` 를 참고하세요.

- 이 엔드포인트는 `Authorization: Bearer <덕링 액세스 토큰>` 으로 인증합니다
- **게임 클라이언트 직접 호출이 허용되어 있습니다** (CSRF Origin 검증 예외 처리됨)
- 인게임 공지(이 문서의 범위)는 계정 연동과 **무관하게** 동작합니다 — 로그인 없이도 보입니다

> 현재 웹뷰 공지는 **읽기 전용**입니다. 인게임에서 글쓰기·댓글까지 하려면
> 웹뷰 자동 로그인(일회용 티켓 교환)이 추가로 필요합니다. 필요해지면 요청하세요.

---

## 9. 체크리스트 / 주의사항

**게임 클라이언트**

- [ ] `DuckringConfig.GameSlug` 를 실제 슬러그로 변경 (`NYMPH` 등, **대소문자 구분**)
- [ ] 공지 로딩 타임아웃 5초, 실패 시 **조용히 스킵** — 게임 진입을 막지 말 것
- [ ] 실행 시 호출은 `/popup` 1회. 매 프레임/매 씬 호출 금지 (CDN 캐시 60초)
- [ ] 이미지는 `thumbnail`/`image_url` 을 lazy 로드하고, 실패해도 팝업은 띄울 것
- [ ] `popup: null` / `items: []` 는 **정상 응답**. 에러로 처리하지 말 것

**Android**

- [ ] HTTPS만 쓰므로 cleartext 예외 설정 불필요
- [ ] 웹뷰 방식이면 `AndroidManifest.xml` 에 `android.permission.INTERNET` 확인

**iOS**

- [ ] ATS 예외 불필요 (전부 HTTPS)
- [ ] 웹뷰에서 외부 링크를 열 때 `Application.OpenURL` → Safari 로 나가는 동작이
      앱 심사에서 문제되지 않도록, 결제/구독 유도 링크는 넣지 말 것

**운영**

- [ ] 공지는 `/admin/community` 에서 작성, 게시 상태 ON 확인
- [ ] 반영까지 최대 60초 (CDN 캐시). 급하면 1분 기다릴 것
- [ ] 이미지는 덕링 관리자 업로드(Supabase Storage) 사용 권장 — 외부 URL도 동작함

---

## 10. 빠른 테스트 (curl)

```bash
# 공지 목록
curl -s "https://www.duckring.com/api/v1/games/NYMPH/notices" | head -c 600

# 본문 전문까지
curl -s "https://www.duckring.com/api/v1/games/NYMPH/notices?category=all&include=content"

# 실행 시 팝업
curl -s "https://www.duckring.com/api/v1/games/NYMPH/popup"

# 웹뷰 페이지 (브라우저 주소창에 넣고 모바일 모드로 확인)
open "https://www.duckring.com/embed/games/NYMPH/community?close=1"
```

---

## 11. 엔드포인트 요약표

| 메서드 | 경로 | 인증 | 용도 |
|---|---|---|---|
| GET | `/api/v1/games/{slug}/notices` | 없음 | 공지 목록 |
| GET | `/api/v1/games/{slug}/notices/{postId}` | 없음 | 공지 상세 |
| GET | `/api/v1/games/{slug}/popup` | 없음 | 실행 시 팝업 1건 |
| GET | `/embed/games/{slug}/community` | 없음 | 웹뷰 목록 화면 |
| GET | `/embed/games/{slug}/community/{postId}` | 없음 | 웹뷰 상세 화면 |
| POST | `/api/game-accounts/connect` | Bearer | 계정 연동 (선택) |
