11 Commits

Author SHA1 Message Date
tkrmagid
4b14fb479b fix(item): namespace item/generated parent for 26.1.2 model loader (v0.4.2)
Some checks failed
build / build (push) Has been cancelled
The held video_stick item rendered as the default missing-model cube even
with v0.4.1 jar loaded (lang strings resolved, so the mod itself was active).
Root cause confirmed against Fabric 26.1.2 docs: the new model loader no
longer auto-resolves unprefixed parent paths. `item/generated` needs to be
written as `minecraft:item/generated`.

models/item/video_stick.json — parent → minecraft:item/generated.
gradle.properties — 0.4.1 → 0.4.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:02:03 +09:00
tkrmagid
693c1f2cd1 docs: require absolute paths in JVM args, drop %APPDATA% expansion myth
Some checks failed
build / build (push) Has been cancelled
User followed README using %APPDATA% in -Xbootclasspath/a: and the official
Mojang launcher passed the literal string through to Java without expanding
it, so boot classpath ended up empty and video stayed black despite all 4
JavaCV jars being present.

Replaced the %APPDATA% example with an absolute C:\Users\<name>\AppData\
Roaming\... template, added a callout warning that the launcher does not
expand env vars in JVM args, and pointed at `echo %APPDATA%` from cmd as the
way to discover the right path.

Also corrected the ffmpeg jar filenames: the bundle zip uses the short form
(e.g. ffmpeg-windows-x86_64.jar), not the Maven-style
ffmpeg-8.0.1-1.5.13-windows-x86_64.jar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:58:46 +09:00
tkrmagid
137767e75c docs: pin direct Fabric API 26.1.2 download link, warn about version suffix
Some checks failed
build / build (push) Has been cancelled
User reported Incompatible-mods crash because they downloaded
fabric-api-0.140.2+1.21.11.jar from Modrinth (Modrinth's version-filter URL
param does not always restrict the listing to the requested game version).

Replaced the generic search-page link with the direct CDN URL of
fabric-api-0.149.0+26.1.2.jar and added a callout telling readers to verify
the filename suffix ends in +26.1.2.jar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:55:32 +09:00
tkrmagid
6e2ef661ea docs: rewrite README for first-time users on official launcher + Fabric
Some checks failed
build / build (push) Has been cancelled
Previous README used "방법 A/B/C" terminology that confused readers and
recommended Prism over the official launcher. Per user feedback, restructured
into a step-by-step guide assuming the official Mojang launcher:

1. boot 26.1.2 vanilla once to create .minecraft
2. run fabric-installer-1.x for client / 26.1.2 / loader 0.19.2
3. open .minecraft/mods (per-OS instructions)
4. drop fabric-api + video_player-0.4.1.jar, remove old versions
5. install JavaCV — two routes:
   5-A. Prism Launcher (easiest)
   5-B. official launcher via -Xbootclasspath/a: with Windows/macOS/Linux examples
6. verify with /videostick

Moved Maven coords to a developer footnote. Added install verification step
to disambiguate "missing texture" symptom from leftover old-version jars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:44:47 +09:00
tkrmagid
d382babfbd docs: client-side install guide for v0.4.1 + JavaCV 1.5.13
Some checks failed
build / build (push) Has been cancelled
README rewritten for MC 26.1.2 / Java 25 / Fabric Loader 0.19.2 target.
Added JavaCV install section (Prism/MultiMC preferred, JVM args fallback,
Maven coords for builders) pointing at the 2026-02-22 1.5.13 binaries
bundling FFmpeg 8.0.1. Usage section reflects the v0.4.1 invisible-anchor
flow: clicked block = bottom-left, sneak+left-click deletes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:39:02 +09:00
tkrmagid
459b3249a4 fix(render): anchor video to clicked block's bottom-left, EAST/WEST flush
Some checks failed
build / build (push) Has been cancelled
Three fixes for v0.4.1:

1. Video stick item rendered as missing-texture because 26.1.2 requires the
   new client_item descriptor at assets/<mod>/items/<name>.json. Add it; the
   existing models/item/video_stick.json is kept as the underlying model.

2. Quad placement now anchors the local (0,0) corner at the bottom-left of
   the wall face the player clicked, so the clicked block is the BL and the
   video grows up & right. Previously it was centered on the anchor.

3. EAST/WEST face rotations were swapped, which placed the quad on the far
   side of the air block (~1 block away from the wall) instead of flush.
   Derived the correct rotations from first principles:
     EAST = Axis.YP +90°  (local +Z → world +X, +X → -Z = north)
     WEST = Axis.YP -90°  (local +Z → world -X, +X → +Z = south)
   NORTH/SOUTH/UP/DOWN math re-verified — those were already correct.
2026-05-15 20:21:19 +09:00
tkrmagid
2b50f56980 render: paint video on the clicked wall face (no visible anchor block)
Some checks failed
build / build (push) Has been cancelled
The anchor block becomes invisible and non-collidable; it exists only as a
BlockEntity host in the air block adjacent to the clicked wall. The renderer
now translates and rotates the textured quad so it sits flush against the
surface of the wall the user actually clicked, on any of the six faces.

Stick interaction:
  right-click face → place anchor at hit.relative(face), facing=face, open GUI
  right-click face with anchor already there → reopen the GUI
  sneak + left-click face with stick → delete the anchor on that face
The anchor's selection outline / collision / occlusion are all empty, so the
player can target the wall block behind it without interference.

JavaCV / streaming polish:
- Bump missing-JavaCV log to WARN so users notice when the runtime jar is
  not installed (previously buried at INFO).
- Add HTTP resilience options: `timeout`, `reconnect`, `reconnect_streamed`,
  `reconnect_at_eof`, and a `user_agent` so picky servers don't 403 us.
2026-05-15 20:08:33 +09:00
tkrmagid
429244d820 audio: route JavaCV samples through SourceDataLine with live gain
Some checks failed
build / build (push) Has been cancelled
setVolume/Mute previously stored gain without affecting audible output: the
backend only called grabImage() and never opened an audio sink. Switch to
grab() (interleaved video+audio frames), force AV_SAMPLE_FMT_S16 on the
grabber so samples are always interleaved signed 16-bit PCM, open a matching
JavaSound SourceDataLine and write scaled samples per-frame. gain is read
on every block so /videoMute, GUI Mute and the per-tick distance attenuation
now take effect immediately. SourceDataLine.write blocking provides natural
A/V pacing, so the legacy 15ms sleep is dropped when an audio line is open;
sleep is retained as a 60fps cap when there is no audio device.

bump version to 0.3.1.
2026-05-15 19:45:42 +09:00
tkrmagid
52fbcd1861 render: restore textured quad on new 26.1.2 BlockEntityRenderer pipeline
Some checks failed
build / build (push) Has been cancelled
VideoPlayback now allocates a DynamicTexture per active anchor under a unique
Identifier (registered on Minecraft.getTextureManager()) and pumps RGBA frames
into it via NativeImage.setPixelABGR + DynamicTexture.upload() during the
client tick. Until the backend (JavaCV) produces a first frame, the texture
shows a dark gray placeholder with a thin border so the anchor screen is
visibly present.

VideoAnchorRenderer.submit() now uses SubmitNodeCollector.submitCustomGeometry
with RenderTypes.entityCutout(textureId), drawing a two-sided width×height
quad oriented by Direction.toYRot() + Axis.YP.rotationDegrees. Vertex
attributes use the new VertexConsumer fluent API (addVertex(Matrix4f, ...)
.setColor.setUv.setOverlay(NO_OVERLAY).setLight.setNormal).

JavaCvBackend / WatermediaBackend / WatermediaProbe / VideoBackend are
unchanged — JavaCV is referenced entirely via reflection so the mod jar
remains loadable when the bytedeco classifier jars aren't on the runtime
classpath, in which case the anchor renders its placeholder surface.
2026-05-15 19:38:23 +09:00
tkrmagid
27a3f34bfa port: migrate all sources from Yarn 1.21.x to Mojmap 26.1.2
Some checks failed
build / build (push) Has been cancelled
- Block/BE/Item: BaseEntityBlock + useItemOn(InteractionResult), useOn(UseOnContext),
  setChanged(), loadAdditional(ValueInput) / saveAdditional(ValueOutput) with
  getStringOr/getIntOr/getBooleanOr/getFloatOr defaults
- Registries: BuiltInRegistries + ResourceKey + Properties.setId(ResourceKey)
- Networking: CustomPacketPayload.Type + StreamCodec.composite + RegistryFriendlyByteBuf
  (note: clientboundPlay/serverboundPlay names in fabric-networking-api-v1 6.3.1)
- Commands: Commands.literal/argument, CommandSourceStack.sendSuccess/sendFailure,
  PermissionSet.hasPermission(Permissions.COMMANDS_GAMEMASTER) (level-2 equivalent)
- Client GUI: EditBox / Button / Checkbox / AbstractSliderButton + addRenderableWidget
  (no render override; widgets render themselves under the new pipeline)
- Renderer: rewritten as stub against new BlockEntityRenderer<T, S extends BlockEntityRenderState>
  pattern (createRenderState / extractRenderState / submit). Stub does not draw a quad yet
  — frame upload and dynamic texture surface deferred until Watermedia/JavaCV are
  re-audited for Java 25
- Playback: stripped to bookkeeping-only stub (tracks active anchors, no frame pump)
- Client entrypoint: ClientTickEvents.END_LEVEL_TICK (was END_WORLD_TICK), Minecraft.level,
  LocalPlayer, Vec3, InteractionResult

./gradlew build passes against MC 26.1.2 + Fabric Loader 0.19.2 + fabric-api 0.149.0+26.1.2.
Block placement, anchor BE, payloads, commands, and GUI are functional; the anchor renders
as the plain block until the new render-state pipeline is wired with a texture.
2026-05-15 19:27:12 +09:00
tkrmagid
8f69814cb2 build: switch toolchain to MC 26.1.2 (intermediary retired)
Some checks failed
build / build (push) Has been cancelled
- net.fabricmc.fabric-loom 1.16-SNAPSHOT (no remap; MC 26.1+ ships unobfuscated)
- gradle.properties: minecraft_version=26.1.2, loader=0.19.2, fabric-api=0.149.0+26.1.2
- Java 25 toolchain
- fabric.mod.json: fabricloader>=0.19.0, java>=25
- Drop multi-version build script + matrix CI (single-target now)
- Backup of 1.21.6/7/8 working tree preserved on mc-1.21.x branch

Source migration to Mojmap names is in progress on follow-up commits;
this commit alone will not build until source files are ported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 19:09:59 +09:00
30 changed files with 1044 additions and 676 deletions

View File

@@ -1,4 +1,4 @@
name: build-matrix
name: build
on:
push:
@@ -9,33 +9,15 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- mc: "1.21.6"
yarn: "1.21.6+build.1"
fabric: "0.120.1+1.21.6"
- mc: "1.21.7"
yarn: "1.21.7+build.8"
fabric: "0.129.0+1.21.7"
- mc: "1.21.8"
yarn: "1.21.8+build.1"
fabric: "0.136.1+1.21.8"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build (MC ${{ matrix.mc }})
run: |
./gradlew --no-daemon \
-Pminecraft_version=${{ matrix.mc }} \
-Pyarn_mappings=${{ matrix.yarn }} \
-Pfabric_version=${{ matrix.fabric }} \
build
java-version: 25
- name: Build (MC 26.1.2)
run: ./gradlew --no-daemon build
- uses: actions/upload-artifact@v4
with:
name: video_player-mc${{ matrix.mc }}
name: video_player-mc26.1.2
path: build/libs/*.jar

193
README.md
View File

@@ -1,29 +1,184 @@
# video_player (영상재생모드)
마인크래프트(Fabric, MC 1.21.6+) 안에서 임의의 mp4 URL을 블록 표면에 재생하는 모드.
마인크래프트 안에서 임의의 동영상 URL을 벽·바닥·천장에 평면으로 재생하는 Fabric 모드.
- 모드 ID: `video_player`
- 한글명: 영상재생모드
- 로더: Fabric (싱글플레이어 / 전용 서버 양쪽 지원)
- 명세: 별도 SPEC 문서 참조
- 현재 버전: **0.4.1**
- 마인크래프트 버전: **26.1.2**
- 필요 Java: **25** (마인크래프트 26.x 가 요구함)
## 빌드
---
## 처음 사용하는 분을 위한 설치 가이드
이 모드는 마인크래프트 **공식 런처**에 **Fabric**을 설치해서 쓰는 것을 기준으로 합니다. 차근차근 따라오시면 됩니다.
### STEP 1. 마인크래프트 공식 런처를 켜고 최소 1회 26.1.2 바닐라로 접속
런처 메뉴에서 마인크래프트 버전을 **26.1.2** 로 한 번 실행해 두면, 게임 폴더(`.minecraft`)와 `versions/26.1.2/` 가 자동으로 만들어집니다. 월드를 만들 필요는 없고 메인 화면까지만 들어가면 됩니다.
### STEP 2. Fabric 설치하기
Fabric은 마인크래프트에 모드 기능을 추가해 주는 로더입니다.
1. https://fabricmc.net/use/installer/ 에 접속해서 "Download for Windows" (또는 macOS / Linux)를 누릅니다. `fabric-installer-1.x.x.exe` (또는 `.jar`) 파일이 다운로드 됩니다.
2. 받은 파일을 **더블 클릭** 으로 실행합니다.
3. 창이 뜨면:
- **클라이언트** 탭이 선택되어 있는지 확인
- 마인크래프트 버전: **26.1.2**
- 로더 버전: **0.19.2** (또는 그보다 높은 숫자)
- 설치 위치는 그대로 두세요
4. **설치** 버튼을 누르고 완료 메시지가 뜨면 닫습니다.
5. 다시 공식 마인크래프트 런처를 열면 좌측 하단 프로필 선택 칸에 **`fabric-loader-0.19.2-26.1.2`** (이름 비슷한 항목) 이 새로 생겨 있습니다. 이 프로필을 선택합니다.
### STEP 3. 모드 폴더 열기
선택한 fabric 프로필 옆에 톱니바퀴 ⚙ 모양 아이콘이나 "편집" 버튼이 있습니다. (없으면 그냥 한 번 플레이를 눌러서 게임을 띄웠다가 닫으면 폴더가 만들어집니다.)
`.minecraft/mods` 폴더가 모드를 넣는 곳입니다. 운영체제별 경로:
- **Windows**: 키보드에서 `윈도우키+R``%appdata%\.minecraft\mods` 입력 → 엔터
- **macOS**: Finder → `Go` 메뉴 → `Go to Folder``~/Library/Application Support/minecraft/mods`
- **Linux**: `~/.minecraft/mods`
폴더가 없으면 `mods` 라는 이름으로 직접 만드세요.
### STEP 4. 모드 jar 파일 두 개를 mods 폴더에 넣기
1. **Fabric API** (Fabric 모드들이 공통으로 쓰는 라이브러리. 거의 모든 Fabric 모드에 필요)
- **반드시 26.1.2 용으로 받아야 합니다.** 파일명 끝에 `+26.1.2.jar` 가 붙어있는지 꼭 확인하세요. `+1.21.11.jar` 같은 다른 버전을 받으면 게임이 "Incompatible mods found / requires Minecraft 1.21.x" 에러로 안 켜집니다.
- 직접 다운로드 (2026-05-14 빌드, MC 26.1.2 전용):
https://cdn.modrinth.com/data/P7dR8mSH/versions/Sy2Bq7Xc/fabric-api-0.149.0%2B26.1.2.jar
- 더 최신 빌드를 찾을 땐: https://modrinth.com/mod/fabric-api/versions → 페이지에서 게임 버전 필터 `26.1.2` 를 직접 선택. (URL 파라미터 필터가 듣지 않는 경우가 있어서 페이지 안에서 한 번 더 확인하는 게 안전합니다.)
- 받은 `fabric-api-0.149.0+26.1.2.jar``mods` 폴더에 넣습니다.
2. **video_player** (이 모드)
- 다운로드: https://git.tkrmagid.kr/tkrmagid/mc_video_player_mod/releases
- `video_player-0.4.1.jar` 를 다운로드해서 같은 `mods` 폴더에 넣습니다.
이전 버전(`video_player-0.4.0.jar`, `0.3.x.jar` 등)이 mods 폴더에 남아있다면 **반드시 삭제**하세요. 두 개가 같이 있으면 마인크래프트가 충돌로 켜지지 않습니다.
### STEP 5. 영상 재생 라이브러리 (JavaCV) 설치
여기까지만 하면 마인크래프트는 켜지지만, 영상 자리에 검은 판만 보입니다. 진짜 영상을 재생하려면 **JavaCV** 라는 디코더 라이브러리가 필요합니다.
> 솔직한 안내: 마인크래프트 공식 런처는 mods 폴더에 들어있는 일반 라이브러리 jar를 자동으로 읽어 주지 않습니다. 그래서 JavaCV 설치 절차가 조금 번거롭습니다. 가장 쉬운 길과 공식 런처에서 동작시키는 길 두 가지를 안내합니다.
#### 5-A. 가장 쉬운 길: Prism Launcher 로 갈아타기 (선택)
공식 런처 대신 무료 오픈소스 런처인 **Prism Launcher** 를 쓰면 클릭 몇 번으로 JavaCV를 라이브러리로 추가할 수 있습니다. 게임 자체는 같고, 정품 마인크래프트 계정으로 로그인하는 것도 동일합니다.
1. https://prismlauncher.org/download/ 에서 다운로드 → 설치
2. Prism에서 인스턴스 → 마인크래프트 26.1.2 + Fabric Loader 0.19.2 선택해서 생성
3. 인스턴스 우클릭 → **Edit** → 왼쪽의 **Version** 탭 → **Add to Minecraft.jar** 버튼
4. 아래 STEP 5-B 의 1번 항목에서 받은 JavaCV jar 4개를 한꺼번에 선택해서 추가
5. **Launch** 로 실행
이 방법이 가장 안정적이고 빠릅니다.
#### 5-B. 공식 런처에서 동작시키기
공식 런처를 계속 쓰고 싶다면 아래 절차를 따르세요.
1. **JavaCV 1.5.13 다운로드**
- 다운로드 링크: https://github.com/bytedeco/javacv/releases/download/1.5.13/javacv-platform-1.5.13-bin.zip (2026-02-22 릴리스, FFmpeg 8.0.1 동봉)
- 압축을 풀고 `javacv-platform-1.5.13-bin` 폴더 안에서 **다음 jar 4개**를 골라 둡니다 (다른 파일은 안 씁니다):
- `javacv.jar`
- `javacpp.jar`
- `ffmpeg.jar`
- 자신의 OS에 맞는 ffmpeg 네이티브 jar **하나**:
- Windows 64bit: `ffmpeg-8.0.1-1.5.13-windows-x86_64.jar`
- macOS Intel: `ffmpeg-8.0.1-1.5.13-macosx-x86_64.jar`
- macOS Apple Silicon (M1/M2/M3/M4): `ffmpeg-8.0.1-1.5.13-macosx-arm64.jar`
- Linux 64bit: `ffmpeg-8.0.1-1.5.13-linux-x86_64.jar`
2. **이 4개 jar 를 게임이 읽도록 등록**
- 가장 안전한 위치: `.minecraft/libraries/javacv/` 폴더를 새로 만들고 4개 jar를 거기에 복사하세요.
- 그 다음, 공식 런처에서 fabric 프로필 옆 **편집** 또는 **설치 설정** → 화면 아래쪽 **"JVM 인수"** (Java arguments) 칸을 켜고 기존 인수 **끝**에 한 칸 띄우고 다음 한 줄을 추가합니다.
**반드시 절대경로(`C:\Users\...`)로 적어주세요.** 마인크래프트 공식 런처는 JVM 인수의 `%APPDATA%`·`%USERPROFILE%` 같은 환경변수를 풀어주지 않고 글자 그대로 Java 에 넘깁니다. 그러면 boot classpath 가 빈 상태가 되어 영상이 안 나옵니다.
Windows (사용자명 `홍길동` 예시):
```
-Xbootclasspath/a:C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\javacv.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\javacpp.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\ffmpeg.jar;C:\Users\홍길동\AppData\Roaming\.minecraft\libraries\javacv\ffmpeg-windows-x86_64.jar
```
(`C:\Users\<본인 윈도우 사용자명>\AppData\Roaming\` 부분을 본인 사용자 폴더로 바꾸세요. 윈도우키+R → `cmd``echo %APPDATA%` 로 정확한 경로 확인 가능.)
macOS:
```
-Xbootclasspath/a:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/javacv.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/javacpp.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/ffmpeg.jar:/Users/사용자이름/Library/Application Support/minecraft/libraries/javacv/ffmpeg-macosx-arm64.jar
```
Linux:
```
-Xbootclasspath/a:/home/사용자이름/.minecraft/libraries/javacv/javacv.jar:/home/사용자이름/.minecraft/libraries/javacv/javacpp.jar:/home/사용자이름/.minecraft/libraries/javacv/ffmpeg.jar:/home/사용자이름/.minecraft/libraries/javacv/ffmpeg-linux-x86_64.jar
```
주의:
- Windows는 jar 사이를 **세미콜론(`;`)** 으로 구분합니다.
- macOS/Linux는 **콜론(`:`)** 으로 구분합니다.
- 경로에 띄어쓰기가 있으면(특히 macOS의 `Application Support`) 전체 인수를 큰따옴표로 감싸세요.
- `사용자이름` 부분은 실제 자신의 사용자 폴더 이름으로 바꾸세요.
- ffmpeg-... 부분의 jar 이름은 OS에 맞춰 1번에서 고른 그 파일명 그대로 적습니다 (zip 안에는 `ffmpeg-windows-x86_64.jar` 같은 짧은 이름으로 들어있습니다).
3. 저장 후 **플레이** 를 누르면 영상이 재생됩니다.
> 절차가 너무 번거롭다고 느끼면 **5-A 의 Prism Launcher** 를 권장합니다. 동일한 jar 를 클릭으로 끌어다 놓기만 하면 끝납니다.
### STEP 6. 잘 설치됐는지 확인
게임 안에서 채팅창에 `/videostick` 을 입력하세요. 정상이라면:
- 인벤토리에 **비디오 스틱** 아이템이 들어옵니다 (보라/검정 missing-texture 가 아니라 작대기 모양 아이콘).
- 보라/검정 missing texture 가 나오면 **STEP 4** 에서 이전 버전 jar가 mods 폴더에 같이 남아있는 경우입니다. 다 지우고 0.4.1 만 남기고 다시 시작하세요.
---
## 사용법
### 영상 배치
1. 비디오 스틱을 손에 들고, 영상을 띄우고 싶은 벽/바닥/천장 블록을 **우클릭**.
2. 열린 GUI 에 영상 URL, 가로(W), 세로(H), 반복 여부, 자동재생 여부를 입력.
3. **클릭한 그 블록의 면** 이 영상의 왼쪽 아래 모서리가 되고, 오른쪽으로 W블록, 위로 H블록 만큼 영상이 펼쳐집니다.
### 영상 수정 / 삭제
- 이미 영상이 걸린 면을 다시 **우클릭** → GUI 재오픈, 내용 수정 가능
- 영상 삭제: **쉬프트(Shift)** 누른 상태로 그 면을 **좌클릭**
### URL 조건
- `http://` 또는 `https://` URL만 됩니다 (`file://`, 로컬 파일 X)
- 길이 256자 이하
- FFmpeg 가 디코드 가능한 형식이면 됩니다 — mp4, webm, mkv, mov 등
- 인증 토큰이 URL 에 들어 있으면 그 상태로 저장되니 공유 주의
### 명령어
| 명령 | 설명 |
| --- | --- |
| `/videostick` | 비디오 스틱 아이템을 인벤토리에 지급 |
| `/videoplace <url> [w] [h] [loop] [autoplay]` | 정조준한 블록 면에 즉시 영상 배치 |
| `/videodelete` | 정조준한 블록 면의 영상 제거 |
---
## 알려진 이슈
- JavaCV가 안 잡혀있으면 영상 자리만 잡힐 뿐 검게 보입니다. 로그 파일(`.minecraft/logs/latest.log`)에서 `JavaCV not on classpath` WARN 메시지로 확인 가능합니다.
- 0.3.x 이하 버전에서 만든 영상은 새 버전(0.4.x) 에서 보이지 않으니 다시 배치해야 합니다.
---
## 개발자용 빌드
```sh
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 ./gradlew build
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 ./gradlew build
```
산출물: `build/libs/video_player-<version>.jar`
산출물: `build/libs/video_player-0.4.1.jar`
## 현재 진행도
이 저장소는 SPEC §10 마일스톤을 순차적으로 채우는 중입니다.
- **M1 — 스캐폴드** (현재): Fabric 모드 골격, 비디오 앵커 블록/엔티티 등록, 비디오 스틱 아이템, `/videoStick` 명령.
- M2 — 정적 렌더
- M3 — JavaCV mp4 재생
- M4 — GUI / 네트워크
- M5 — 좌클릭 삭제 + `/videoPlace`, `/videoDelete`
- M6 — 오디오 + 거리감 + Mute(소리 on/off)
- M7 — WaterMedia 백엔드 + 자동 선택
- M8 — Stonecutter 멀티버전
JavaCV를 직접 의존성으로 가져오는 경우의 Maven 좌표:
```
org.bytedeco:javacv-platform:1.5.13
```

View File

@@ -1,5 +1,5 @@
plugins {
id 'fabric-loom' version '1.16.2'
id 'net.fabricmc.fabric-loom' version "${loom_version}"
id 'maven-publish'
id 'java'
}
@@ -16,11 +16,16 @@ repositories {
maven { url = 'https://maven.fabricmc.net/' }
}
loom {
// Intentionally empty — MC 26.1+ ships unobfuscated, so the new loom does not remap.
}
dependencies {
// No mappings dep — Mojang ships official names since 26.1, intermediary is gone.
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
}
processResources {
@@ -28,27 +33,24 @@ processResources {
inputs.property "mod_id", project.mod_id
inputs.property "minecraft_version", project.minecraft_version
// Pin the fabric.mod.json's minecraft constraint to the build's exact target — keeps each
// multi-version jar from claiming compatibility it doesn't have.
def mc = project.minecraft_version
def target = "~${mc}"
filesMatching("fabric.mod.json") {
expand "version": project.version, "mod_id": project.mod_id, "target_minecraft": target
expand "version": project.version,
"mod_id": project.mod_id,
"target_minecraft": "~${project.minecraft_version}"
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
it.options.release = 25
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
toolchain {
languageVersion = JavaLanguageVersion.of(21)
languageVersion = JavaLanguageVersion.of(25)
}
}

View File

@@ -1,14 +1,16 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
# Config cache disabled — new loom + IntelliJ aren't fully compatible yet
org.gradle.configuration-cache=false
# Mod
mod_id=video_player
mod_version=0.2.0
mod_version=0.4.2
maven_group=com.ejclaw.videoplayer
archives_base_name=video_player
# Minecraft / Fabric (1.21.6)
minecraft_version=1.21.6
yarn_mappings=1.21.6+build.1
# Minecraft / Fabric (26.1.2 — single target, intermediary/Yarn retired)
minecraft_version=26.1.2
loader_version=0.19.2
fabric_version=0.120.1+1.21.6
loom_version=1.16-SNAPSHOT
fabric_version=0.149.0+26.1.2

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env bash
# Build the mod against all supported MC versions and collect jars under build/multiver/.
# Uses gradle property overrides so we don't have to maintain three settings.gradle copies.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=build/multiver
rm -rf "$OUT"
mkdir -p "$OUT"
build_one() {
local mc="$1" yarn="$2" fab="$3"
echo "==> Building for MC $mc (yarn=$yarn, fabric-api=$fab)"
./gradlew --no-daemon \
-Pminecraft_version="$mc" \
-Pyarn_mappings="$yarn" \
-Pfabric_version="$fab" \
build
# main jar = the one without "-sources"
local jar
jar=$(ls build/libs/video_player-*.jar | grep -v -- '-sources' | head -1)
cp "$jar" "$OUT/video_player-mc${mc}.jar"
}
build_one 1.21.6 1.21.6+build.1 0.120.1+1.21.6
build_one 1.21.7 1.21.7+build.8 0.129.0+1.21.7
build_one 1.21.8 1.21.8+build.1 0.136.1+1.21.8
echo
echo "All jars:"
ls -la "$OUT"

View File

@@ -14,17 +14,15 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.fabricmc.fabric.api.client.rendering.v1.BlockEntityRendererRegistry;
import net.fabricmc.fabric.api.event.player.AttackBlockCallback;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.network.ClientPlayerEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.util.ActionResult;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.phys.Vec3;
@Environment(EnvType.CLIENT)
public class VideoPlayerClient implements ClientModInitializer {
@SuppressWarnings("deprecation")
@Override
public void onInitializeClient() {
ClientNetworking.register();
@@ -34,14 +32,25 @@ public class VideoPlayerClient implements ClientModInitializer {
VideoAnchorRenderer::new
);
AttackBlockCallback.EVENT.register((player, world, hand, pos, direction) -> {
if (world.isClient
&& player.getMainHandStack().getItem() instanceof VideoStickItem
&& world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
return ActionResult.SUCCESS;
AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> {
if (!level.isClientSide()) return InteractionResult.PASS;
if (!(player.getMainHandItem().getItem() instanceof VideoStickItem)) return InteractionResult.PASS;
// The anchor itself is invisible / non-collidable so the player cannot left-click it
// directly. Sneak + left-click on the wall the video sits on → delete the anchor in
// the adjacent air block.
if (player.isShiftKeyDown()) {
BlockPos anchorPos = pos.relative(direction);
if (level.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(anchorPos));
return InteractionResult.SUCCESS;
}
}
return ActionResult.PASS;
// Legacy / safety: if the player somehow targets the anchor block directly.
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
});
ClientTickEvents.END_CLIENT_TICK.register(client -> {
@@ -49,7 +58,7 @@ public class VideoPlayerClient implements ClientModInitializer {
updateDistanceGains(client);
});
ClientTickEvents.END_WORLD_TICK.register(world -> {
ClientTickEvents.END_LEVEL_TICK.register(world -> {
// no-op for now
});
@@ -57,19 +66,19 @@ public class VideoPlayerClient implements ClientModInitializer {
}
/** SPEC §6 — recompute per-anchor audio gain from player distance every tick. */
private static void updateDistanceGains(MinecraftClient client) {
ClientPlayerEntity p = client.player;
if (p == null || client.world == null) return;
Vec3d eye = p.getEyePos();
for (BlockPos pos : com.ejclaw.videoplayer.client.playback.VideoPlayback.activePositions()) {
if (!(client.world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
double dx = (pos.getX() + 0.5) - eye.x;
double dy = (pos.getY() + 0.5) - eye.y;
double dz = (pos.getZ() + 0.5) - eye.z;
double d = Math.sqrt(dx * dx + dy * dy + dz * dz);
float attenuation = (float) Math.max(0.0, Math.min(1.0, 1.0 - d / 16.0));
float gain = be.isMuted() ? 0F : be.getVolume() * attenuation;
com.ejclaw.videoplayer.client.playback.VideoPlayback.setGain(pos, gain);
VideoPlayback.setGain(pos, gain);
}
}
}

View File

@@ -4,50 +4,87 @@ import com.ejclaw.videoplayer.item.VideoStickItem;
import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.mojang.serialization.MapCodec;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.AbstractBlock;
import net.minecraft.block.BlockEntityProvider;
import net.minecraft.block.BlockState;
import net.minecraft.block.BlockWithEntity;
import net.minecraft.block.entity.BlockEntity;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.ActionResult;
import net.minecraft.util.Hand;
import net.minecraft.util.hit.BlockHitResult;
import net.minecraft.util.math.BlockPos;
import net.minecraft.world.World;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
public class VideoAnchorBlock extends BlockWithEntity implements BlockEntityProvider {
public static final MapCodec<VideoAnchorBlock> CODEC = createCodec(VideoAnchorBlock::new);
/**
* Anchor block — invisible, non-collidable host for {@link VideoAnchorBlockEntity}.
*
* <p>The block exists only so a {@link BlockEntity} can be attached to a position; visually it is
* completely empty (no model, no selection outline, no collision). The video itself is drawn by
* {@link com.ejclaw.videoplayer.client.render.VideoAnchorRenderer} flush against the wall the
* player clicked, not as a textured surface on this block.
*/
public class VideoAnchorBlock extends BaseEntityBlock {
public static final MapCodec<VideoAnchorBlock> CODEC = simpleCodec(VideoAnchorBlock::new);
public VideoAnchorBlock(AbstractBlock.Settings settings) {
super(settings);
public VideoAnchorBlock(BlockBehaviour.Properties properties) {
super(properties);
}
@Override
protected MapCodec<? extends BlockWithEntity> getCodec() {
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
public BlockEntity createBlockEntity(BlockPos pos, BlockState state) {
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new VideoAnchorBlockEntity(pos, state);
}
@Override
protected ActionResult onUseWithItem(ItemStack stack, BlockState state, World world,
BlockPos pos, PlayerEntity player, Hand hand,
BlockHitResult hit) {
protected RenderShape getRenderShape(BlockState state) {
return RenderShape.INVISIBLE;
}
@Override
protected VoxelShape getShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getCollisionShape(BlockState state, BlockGetter level, BlockPos pos, CollisionContext ctx) {
return Shapes.empty();
}
@Override
protected VoxelShape getOcclusionShape(BlockState state) {
return Shapes.empty();
}
@Override
protected boolean propagatesSkylightDown(BlockState state) {
return true;
}
@Override
protected InteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (!(stack.getItem() instanceof VideoStickItem)) {
return ActionResult.PASS;
return InteractionResult.PASS;
}
if (world.isClient) return ActionResult.SUCCESS;
if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS;
if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be) {
if (level.isClientSide()) return InteractionResult.SUCCESS;
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(pos, be.toNbt()));
return ActionResult.SUCCESS;
return InteractionResult.SUCCESS;
}
return ActionResult.PASS;
return InteractionResult.PASS;
}
}

View File

@@ -1,17 +1,18 @@
package com.ejclaw.videoplayer.block;
import com.ejclaw.videoplayer.registry.VideoPlayerBlockEntities;
import net.minecraft.block.BlockState;
import net.minecraft.block.entity.BlockEntity;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.storage.ReadView;
import net.minecraft.storage.WriteView;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
/**
* Anchor BE — holds the per-block config that drives playback. NBT persistence uses
* 1.21.6's ReadView/WriteView. Network sync uses {@link #toNbt()} / {@link #fromNbt(NbtCompound)}.
* Anchor BE — holds per-block config that drives playback.
* NBT persistence uses 26.1's ValueInput/ValueOutput.
* Network sync uses {@link #toNbt()} / {@link #fromNbt(CompoundTag)}.
*/
public class VideoAnchorBlockEntity extends BlockEntity {
private String url = "";
@@ -36,28 +37,28 @@ public class VideoAnchorBlockEntity extends BlockEntity {
public boolean isMuted() { return muted; }
public boolean isAutoplay() { return autoplay; }
public void setUrl(String url) { this.url = url == null ? "" : url; markDirty(); }
public void setWidth(int width) { this.width = clamp(width, 1, 32); markDirty(); }
public void setHeight(int height) { this.height = clamp(height, 1, 32); markDirty(); }
public void setFacing(Direction facing) { this.facing = facing == null ? Direction.NORTH : facing; markDirty(); }
public void setLoop(boolean loop) { this.loop = loop; markDirty(); }
public void setVolume(float volume) { this.volume = Math.max(0F, Math.min(1F, volume)); markDirty(); }
public void setMuted(boolean muted) { this.muted = muted; markDirty(); }
public void setAutoplay(boolean autoplay) { this.autoplay = autoplay; markDirty(); }
public void setUrl(String url) { this.url = url == null ? "" : url; setChanged(); }
public void setWidth(int width) { this.width = clamp(width, 1, 32); setChanged(); }
public void setHeight(int height) { this.height = clamp(height, 1, 32); setChanged(); }
public void setFacing(Direction facing) { this.facing = facing == null ? Direction.NORTH : facing; setChanged(); }
public void setLoop(boolean loop) { this.loop = loop; setChanged(); }
public void setVolume(float volume) { this.volume = Math.max(0F, Math.min(1F, volume)); setChanged(); }
public void setMuted(boolean muted) { this.muted = muted; setChanged(); }
public void setAutoplay(boolean autoplay) { this.autoplay = autoplay; setChanged(); }
/** Apply server-validated config from an NBT (used by network handler). */
public void applyFromNbt(NbtCompound nbt) {
public void applyFromNbt(CompoundTag nbt) {
fromNbt(nbt);
markDirty();
setChanged();
}
/** Wire-format NBT used by SaveConfig/SyncAnchor payloads. */
public NbtCompound toNbt() {
NbtCompound nbt = new NbtCompound();
public CompoundTag toNbt() {
CompoundTag nbt = new CompoundTag();
nbt.putString("url", url);
nbt.putInt("width", width);
nbt.putInt("height", height);
nbt.putString("facing", facing.asString());
nbt.putString("facing", facing.getSerializedName());
nbt.putBoolean("loop", loop);
nbt.putFloat("volume", volume);
nbt.putBoolean("muted", muted);
@@ -65,43 +66,51 @@ public class VideoAnchorBlockEntity extends BlockEntity {
return nbt;
}
public void fromNbt(NbtCompound nbt) {
this.url = clampUrl(nbt.getString("url", ""));
this.width = clamp(nbt.getInt("width", 1), 1, 32);
this.height = clamp(nbt.getInt("height", 1), 1, 32);
Direction d = Direction.byId(nbt.getString("facing", "north"));
public void fromNbt(CompoundTag nbt) {
this.url = clampUrl(nbt.getStringOr("url", ""));
this.width = clamp(nbt.getIntOr("width", 1), 1, 32);
this.height = clamp(nbt.getIntOr("height", 1), 1, 32);
Direction d = directionFromName(nbt.getStringOr("facing", "north"));
this.facing = d == null ? Direction.NORTH : d;
this.loop = nbt.getBoolean("loop", true);
this.volume = Math.max(0F, Math.min(1F, nbt.getFloat("volume", 0.5F)));
this.muted = nbt.getBoolean("muted", false);
this.autoplay = nbt.getBoolean("autoplay", true);
this.loop = nbt.getBooleanOr("loop", true);
this.volume = Math.max(0F, Math.min(1F, nbt.getFloatOr("volume", 0.5F)));
this.muted = nbt.getBooleanOr("muted", false);
this.autoplay = nbt.getBooleanOr("autoplay", true);
}
@Override
protected void writeData(WriteView view) {
super.writeData(view);
view.putString("url", url);
view.putInt("width", width);
view.putInt("height", height);
view.putString("facing", facing.asString());
view.putBoolean("loop", loop);
view.putFloat("volume", volume);
view.putBoolean("muted", muted);
view.putBoolean("autoplay", autoplay);
protected void saveAdditional(ValueOutput out) {
super.saveAdditional(out);
out.putString("url", url);
out.putInt("width", width);
out.putInt("height", height);
out.putString("facing", facing.getSerializedName());
out.putBoolean("loop", loop);
out.putFloat("volume", volume);
out.putBoolean("muted", muted);
out.putBoolean("autoplay", autoplay);
}
@Override
protected void readData(ReadView view) {
super.readData(view);
this.url = clampUrl(view.getString("url", ""));
this.width = clamp(view.getInt("width", 1), 1, 32);
this.height = clamp(view.getInt("height", 1), 1, 32);
Direction d = Direction.byId(view.getString("facing", "north"));
protected void loadAdditional(ValueInput in) {
super.loadAdditional(in);
this.url = clampUrl(in.getStringOr("url", ""));
this.width = clamp(in.getIntOr("width", 1), 1, 32);
this.height = clamp(in.getIntOr("height", 1), 1, 32);
Direction d = directionFromName(in.getStringOr("facing", "north"));
this.facing = d == null ? Direction.NORTH : d;
this.loop = view.getBoolean("loop", true);
this.volume = Math.max(0F, Math.min(1F, view.getFloat("volume", 0.5F)));
this.muted = view.getBoolean("muted", false);
this.autoplay = view.getBoolean("autoplay", true);
this.loop = in.getBooleanOr("loop", true);
this.volume = Math.max(0F, Math.min(1F, in.getFloatOr("volume", 0.5F)));
this.muted = in.getBooleanOr("muted", false);
this.autoplay = in.getBooleanOr("autoplay", true);
}
private static Direction directionFromName(String name) {
if (name == null) return null;
for (Direction d : Direction.values()) {
if (d.getSerializedName().equalsIgnoreCase(name)) return d;
}
return null;
}
private static int clamp(int v, int lo, int hi) {

View File

@@ -5,33 +5,32 @@ import com.ejclaw.videoplayer.net.SaveConfigPayload;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.CheckboxWidget;
import net.minecraft.client.gui.widget.SliderWidget;
import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.text.Text;
import net.minecraft.util.math.BlockPos;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.components.AbstractSliderButton;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Checkbox;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
/** SPEC §4.3 — anchor config GUI. Opened by S2C {@code OpenScreenPayload}. */
@Environment(EnvType.CLIENT)
public class VideoConfigScreen extends Screen {
private final BlockPos pos;
private final NbtCompound initial;
private final CompoundTag initial;
private TextFieldWidget urlField;
private TextFieldWidget widthField;
private TextFieldWidget heightField;
private CheckboxWidget loopBox;
private CheckboxWidget muteBox;
private CheckboxWidget autoplayBox;
private EditBox urlField;
private EditBox widthField;
private EditBox heightField;
private Checkbox loopBox;
private Checkbox muteBox;
private Checkbox autoplayBox;
private VolumeSlider volumeSlider;
public VideoConfigScreen(BlockPos pos, NbtCompound data) {
super(Text.literal("Video Anchor"));
public VideoConfigScreen(BlockPos pos, CompoundTag data) {
super(Component.literal("Video Anchor"));
this.pos = pos;
this.initial = data;
}
@@ -41,81 +40,73 @@ public class VideoConfigScreen extends Screen {
int cx = this.width / 2;
int y = 40;
urlField = new TextFieldWidget(this.textRenderer, cx - 150, y, 300, 20, Text.literal("URL"));
urlField = new EditBox(this.font, cx - 150, y, 300, 20, Component.literal("URL"));
urlField.setMaxLength(256);
urlField.setText(initial.getString("url", ""));
addDrawableChild(urlField);
urlField.setValue(initial.getStringOr("url", ""));
addRenderableWidget(urlField);
y += 30;
widthField = new TextFieldWidget(this.textRenderer, cx - 150, y, 60, 20, Text.literal("W"));
widthField = new EditBox(this.font, cx - 150, y, 60, 20, Component.literal("W"));
widthField.setMaxLength(2);
widthField.setText(Integer.toString(initial.getInt("width", 1)));
widthField.setTextPredicate(VideoConfigScreen::isDigits);
addDrawableChild(widthField);
widthField.setValue(Integer.toString(initial.getIntOr("width", 1)));
addRenderableWidget(widthField);
heightField = new TextFieldWidget(this.textRenderer, cx - 80, y, 60, 20, Text.literal("H"));
heightField = new EditBox(this.font, cx - 80, y, 60, 20, Component.literal("H"));
heightField.setMaxLength(2);
heightField.setText(Integer.toString(initial.getInt("height", 1)));
heightField.setTextPredicate(VideoConfigScreen::isDigits);
addDrawableChild(heightField);
heightField.setValue(Integer.toString(initial.getIntOr("height", 1)));
addRenderableWidget(heightField);
volumeSlider = new VolumeSlider(cx - 10, y, 160, 20,
Math.max(0F, Math.min(1F, initial.getFloat("volume", 0.5F))));
addDrawableChild(volumeSlider);
Math.max(0F, Math.min(1F, initial.getFloatOr("volume", 0.5F))));
addRenderableWidget(volumeSlider);
y += 30;
loopBox = CheckboxWidget.builder(Text.literal("Loop"), this.textRenderer)
.pos(cx - 150, y).checked(initial.getBoolean("loop", true)).build();
addDrawableChild(loopBox);
loopBox = Checkbox.builder(Component.literal("Loop"), this.font)
.pos(cx - 150, y).selected(initial.getBooleanOr("loop", true)).build();
addRenderableWidget(loopBox);
muteBox = CheckboxWidget.builder(Text.literal("Mute"), this.textRenderer)
.pos(cx - 60, y).checked(initial.getBoolean("muted", false)).build();
addDrawableChild(muteBox);
muteBox = Checkbox.builder(Component.literal("Mute"), this.font)
.pos(cx - 60, y).selected(initial.getBooleanOr("muted", false)).build();
addRenderableWidget(muteBox);
autoplayBox = CheckboxWidget.builder(Text.literal("Autoplay"), this.textRenderer)
.pos(cx + 30, y).checked(initial.getBoolean("autoplay", true)).build();
addDrawableChild(autoplayBox);
autoplayBox = Checkbox.builder(Component.literal("Autoplay"), this.font)
.pos(cx + 30, y).selected(initial.getBooleanOr("autoplay", true)).build();
addRenderableWidget(autoplayBox);
y += 36;
addDrawableChild(ButtonWidget.builder(Text.literal("Save"), b -> save())
.dimensions(cx - 150, y, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Cancel"), b -> close())
.dimensions(cx - 45, y, 90, 20).build());
addDrawableChild(ButtonWidget.builder(Text.literal("Delete"), b -> delete())
.dimensions(cx + 60, y, 90, 20).build());
addRenderableWidget(Button.builder(Component.literal("Save"), b -> save())
.bounds(cx - 150, y, 90, 20).build());
addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose())
.bounds(cx - 45, y, 90, 20).build());
addRenderableWidget(Button.builder(Component.literal("Delete"), b -> delete())
.bounds(cx + 60, y, 90, 20).build());
}
private void save() {
NbtCompound out = new NbtCompound();
out.putString("url", urlField.getText());
out.putInt("width", parseInt(widthField.getText(), 1));
out.putInt("height", parseInt(heightField.getText(), 1));
out.putString("facing", initial.getString("facing", "north"));
out.putBoolean("loop", loopBox.isChecked());
CompoundTag out = new CompoundTag();
out.putString("url", urlField.getValue());
out.putInt("width", parseInt(widthField.getValue(), 1));
out.putInt("height", parseInt(heightField.getValue(), 1));
out.putString("facing", initial.getStringOr("facing", "north"));
out.putBoolean("loop", loopBox.selected());
out.putFloat("volume", volumeSlider.getVolume());
out.putBoolean("muted", muteBox.isChecked());
out.putBoolean("autoplay", autoplayBox.isChecked());
out.putBoolean("muted", muteBox.selected());
out.putBoolean("autoplay", autoplayBox.selected());
ClientPlayNetworking.send(new SaveConfigPayload(pos, out));
close();
onClose();
}
private void delete() {
ClientPlayNetworking.send(new DeleteAnchorPayload(pos));
close();
onClose();
}
@Override
public void render(DrawContext ctx, int mouseX, int mouseY, float delta) {
super.render(ctx, mouseX, mouseY, delta);
ctx.drawCenteredTextWithShadow(this.textRenderer, this.title, this.width / 2, 16, 0xFFFFFF);
}
public boolean isPauseScreen() { return false; }
@Override
public boolean shouldPause() { return false; }
@Override
public void close() {
MinecraftClient mc = this.client != null ? this.client : MinecraftClient.getInstance();
public void onClose() {
Minecraft mc = this.minecraft != null ? this.minecraft : Minecraft.getInstance();
if (mc != null) mc.setScreen(null);
}
@@ -123,15 +114,9 @@ public class VideoConfigScreen extends Screen {
try { return Integer.parseInt(s); } catch (Exception e) { return dflt; }
}
private static boolean isDigits(String s) {
if (s.isEmpty()) return true;
for (int i = 0; i < s.length(); i++) if (!Character.isDigit(s.charAt(i))) return false;
return true;
}
private static final class VolumeSlider extends SliderWidget {
private static final class VolumeSlider extends AbstractSliderButton {
VolumeSlider(int x, int y, int w, int h, float initial) {
super(x, y, w, h, Text.literal("Volume: " + pct(initial)), initial);
super(x, y, w, h, Component.literal("Volume: " + pct(initial)), initial);
updateMessage();
}
@@ -139,7 +124,7 @@ public class VideoConfigScreen extends Screen {
@Override
protected void updateMessage() {
setMessage(Text.literal("Volume: " + pct((float) this.value)));
setMessage(Component.literal("Volume: " + pct((float) this.value)));
}
@Override

View File

@@ -8,7 +8,7 @@ import com.ejclaw.videoplayer.net.SyncAnchorPayload;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.Minecraft;
/** Client-side S2C receivers for OpenScreen and SyncAnchor. */
@Environment(EnvType.CLIENT)
@@ -16,16 +16,16 @@ public final class ClientNetworking {
private ClientNetworking() {}
public static void register() {
ClientPlayNetworking.registerGlobalReceiver(OpenScreenPayload.ID, (payload, context) -> {
MinecraftClient mc = context.client();
ClientPlayNetworking.registerGlobalReceiver(OpenScreenPayload.TYPE, (payload, context) -> {
Minecraft mc = context.client();
mc.execute(() -> mc.setScreen(new VideoConfigScreen(payload.pos(), payload.data())));
});
ClientPlayNetworking.registerGlobalReceiver(SyncAnchorPayload.ID, (payload, context) -> {
MinecraftClient mc = context.client();
ClientPlayNetworking.registerGlobalReceiver(SyncAnchorPayload.TYPE, (payload, context) -> {
Minecraft mc = context.client();
mc.execute(() -> {
if (mc.world == null) return;
if (mc.world.getBlockEntity(payload.pos()) instanceof VideoAnchorBlockEntity be) {
if (mc.level == null) return;
if (mc.level.getBlockEntity(payload.pos()) instanceof VideoAnchorBlockEntity be) {
be.applyFromNbt(payload.data());
VideoPlayback.onConfigChanged(be);
}

View File

@@ -4,24 +4,34 @@ import com.ejclaw.videoplayer.VideoPlayerMod;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.SourceDataLine;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.ShortBuffer;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* SPEC §5.3 — fallback mp4/http(s) backend driven by JavaCV's FFmpegFrameGrabber.
*
* JavaCV is referenced entirely through reflection so that the mod jar stays loadable when the
* (large) JavaCV dependency isn't bundled — the backend just reports {@code !isReady()} until the
* runtime classpath contains org.bytedeco.javacv.FFmpegFrameGrabber.
* <p>Video frames are decoded through {@code grab()}, audio samples are forced to interleaved
* signed 16-bit PCM ({@code AV_SAMPLE_FMT_S16}) and written to a {@link SourceDataLine} so that
* {@link #setVolume(float)} actually mutes / attenuates audible output. JavaCV is referenced
* entirely through reflection so that the mod jar stays loadable when the (large) JavaCV
* dependency isn't bundled — the backend just reports {@code !isReady()} until the runtime
* classpath contains {@code org.bytedeco.javacv.FFmpegFrameGrabber}.
*/
@Environment(EnvType.CLIENT)
public class JavaCvBackend implements VideoBackend {
private static final String GRABBER_CLASS = "org.bytedeco.javacv.FFmpegFrameGrabber";
private static final String FRAME_CLASS = "org.bytedeco.javacv.Frame";
private static final String CONVERTER_CLASS = "org.bytedeco.javacv.Java2DFrameConverter";
/** {@code AV_SAMPLE_FMT_S16} from {@code org.bytedeco.ffmpeg.global.avutil}. */
private static final int AV_SAMPLE_FMT_S16 = 1;
private final Object lock = new Object();
private Thread worker;
@@ -84,28 +94,46 @@ public class JavaCvBackend implements VideoBackend {
/** Pure-reflection decode loop. Silent fallback if JavaCV isn't present. */
private void runLoop(String url) {
Object grabber = null;
SourceDataLine audioLine = null;
try {
Class<?> grabberCls = Class.forName(GRABBER_CLASS);
grabber = grabberCls.getConstructor(String.class).newInstance(url);
Method start = grabberCls.getMethod("start");
Method stop = grabberCls.getMethod("stop");
Method grab = grabberCls.getMethod("grabImage");
Method grab = grabberCls.getMethod("grab");
Method getW = grabberCls.getMethod("getImageWidth");
Method getH = grabberCls.getMethod("getImageHeight");
Method getSampleRate = grabberCls.getMethod("getSampleRate");
Method getAudioChannels = grabberCls.getMethod("getAudioChannels");
Method setOpt = grabberCls.getMethod("setOption", String.class, String.class);
Method setSampleFormat = grabberCls.getMethod("setSampleFormat", int.class);
// mp4/http(s) network tuning
try { setOpt.invoke(grabber, "rw_timeout", "5000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "stimeout", "5000000"); } catch (Throwable ignored) {}
// HTTP(S) tuning for streaming URLs (e.g. webm via Range / chunked transfer).
try { setOpt.invoke(grabber, "rw_timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "timeout", "10000000"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_streamed", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "reconnect_at_eof", "1"); } catch (Throwable ignored) {}
try { setOpt.invoke(grabber, "user_agent",
"video_player/" + com.ejclaw.videoplayer.VideoPlayerMod.MOD_ID); } catch (Throwable ignored) {}
// Force interleaved signed 16-bit PCM so the audio sink path is single-shape.
try { setSampleFormat.invoke(grabber, AV_SAMPLE_FMT_S16); } catch (Throwable ignored) {}
start.invoke(grabber);
this.width = (int) getW.invoke(grabber);
this.height = (int) getH.invoke(grabber);
this.ready = (width > 0 && height > 0);
Class<?> convCls = Class.forName(CONVERTER_CLASS);
Object converter = convCls.getDeclaredConstructor().newInstance();
Method toImage = convCls.getMethod("getBufferedImage", Class.forName(FRAME_CLASS));
int sampleRate = safeInt(getSampleRate, grabber);
int audioChannels = safeInt(getAudioChannels, grabber);
audioLine = openLine(sampleRate, audioChannels);
Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples");
Class<?> convCls = Class.forName(CONVERTER_CLASS);
Object converter = convCls.getDeclaredConstructor().newInstance();
Method toImage = convCls.getMethod("getBufferedImage", frameCls);
while (running.get() && !closed) {
if (paused.get()) { Thread.sleep(20); continue; }
@@ -118,26 +146,96 @@ public class JavaCvBackend implements VideoBackend {
}
break;
}
java.awt.image.BufferedImage img = (java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img == null) continue;
ByteBuffer buf = toRgba(img);
if (buf != null) latest.set(buf);
Thread.sleep(15); // ~60fps cap
Object[] samples = (Object[]) samplesField.get(frame);
if (samples != null && samples.length > 0 && audioLine != null) {
writeAudio(audioLine, samples, this.gain);
}
Object[] images = (Object[]) imageField.get(frame);
if (images != null && images.length > 0) {
java.awt.image.BufferedImage img =
(java.awt.image.BufferedImage) toImage.invoke(converter, frame);
if (img != null) {
ByteBuffer buf = toRgba(img);
if (buf != null) latest.set(buf);
}
}
// If we have an open audio line, SourceDataLine.write() blocks for backpressure
// and provides natural A/V pacing; otherwise tick ~60fps so we don't busy-loop.
if (audioLine == null) Thread.sleep(15);
}
} catch (ClassNotFoundException cnf) {
VideoPlayerMod.LOG.info("[{}] JavaCV not on classpath; backend inactive", VideoPlayerMod.MOD_ID);
VideoPlayerMod.LOG.warn(
"[{}] JavaCV not on classpath — install org.bytedeco:javacv-platform (or javacv + ffmpeg natives)" +
" to enable video/audio playback. Anchor placeholder will remain visible.",
VideoPlayerMod.MOD_ID);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} catch (Throwable t) {
VideoPlayerMod.LOG.warn("[{}] JavaCV decode error: {}", VideoPlayerMod.MOD_ID, t.toString());
} finally {
ready = false;
if (audioLine != null) {
try { audioLine.drain(); } catch (Throwable ignored) {}
try { audioLine.stop(); } catch (Throwable ignored) {}
try { audioLine.close(); } catch (Throwable ignored) {}
}
if (grabber != null) {
try { grabber.getClass().getMethod("close").invoke(grabber); } catch (Throwable ignored) {}
}
}
}
/** Open a JavaSound output line for the stream's sample rate / channel count, or null. */
private static SourceDataLine openLine(int sampleRate, int channels) {
if (sampleRate <= 0 || channels <= 0) return null;
try {
AudioFormat fmt = new AudioFormat(sampleRate, 16, channels, true, false); // signed 16-bit LE
SourceDataLine line = AudioSystem.getSourceDataLine(fmt);
line.open(fmt);
line.start();
return line;
} catch (Throwable t) {
VideoPlayerMod.LOG.info("[{}] no audio sink ({} Hz x{}): {}",
VideoPlayerMod.MOD_ID, sampleRate, channels, t.toString());
return null;
}
}
/** Scale & write interleaved S16 PCM samples to the audio line. */
private static void writeAudio(SourceDataLine line, Object[] samples, float gain) {
if (!(samples[0] instanceof ShortBuffer sb)) return; // sample format forcing failed
int remaining = sb.remaining();
if (remaining <= 0) return;
byte[] pcm = new byte[remaining * 2];
int idx = 0;
if (gain >= 0.999F) {
while (sb.hasRemaining()) {
short s = sb.get();
pcm[idx++] = (byte) (s & 0xFF);
pcm[idx++] = (byte) ((s >> 8) & 0xFF);
}
} else if (gain <= 0F) {
// Mute: consume but emit silence so the line keeps cadence.
sb.position(sb.limit());
} else {
while (sb.hasRemaining()) {
int scaled = (int) (sb.get() * gain);
if (scaled > 32767) scaled = 32767;
if (scaled < -32768) scaled = -32768;
pcm[idx++] = (byte) (scaled & 0xFF);
pcm[idx++] = (byte) ((scaled >> 8) & 0xFF);
}
}
line.write(pcm, 0, pcm.length);
}
private static int safeInt(Method m, Object target) {
try { return (int) m.invoke(target); } catch (Throwable t) { return 0; }
}
private static ByteBuffer toRgba(java.awt.image.BufferedImage img) {
int w = img.getWidth(), h = img.getHeight();
int[] argb = img.getRGB(0, 0, w, h, null, 0, w);

View File

@@ -1,45 +1,66 @@
package com.ejclaw.videoplayer.client.playback;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.mojang.blaze3d.platform.NativeImage;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.texture.NativeImage;
import net.minecraft.client.texture.NativeImageBackedTexture;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.BlockPos;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.core.BlockPos;
import net.minecraft.resources.Identifier;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} + dynamic
* Identifier of an {@link NativeImageBackedTexture}). The renderer reads the texture id and binds
* it to the quad; this class drives the frame pump every client tick.
* SPEC §5 — per-anchor playback registry. Maps {@link BlockPos} → ({@link VideoBackend} +
* a {@link DynamicTexture} surface registered under a unique {@link Identifier}). The renderer
* reads {@link #currentTexture(BlockPos)} and binds it to the quad. {@link #tick()} pumps
* decoded RGBA frames into the texture.
*
* <p>When no backend is available on the classpath (e.g. JavaCV jar not installed by the
* user), the texture stays at its initial placeholder pattern, so the anchor's screen quad
* still renders as a visible surface.
*/
@Environment(EnvType.CLIENT)
public final class VideoPlayback {
private VideoPlayback() {}
private static final int PLACEHOLDER_SIZE = 32;
private static final Map<BlockPos, Entry> ENTRIES = new HashMap<>();
/**
* Ensure a playback entry exists for this anchor and return its texture identifier.
* Creates a backend + dynamic texture on first call. Returns {@code null} only if the
* URL is empty or autoplay is off.
*/
public static Identifier getOrStart(VideoAnchorBlockEntity be) {
BlockPos pos = be.getPos();
BlockPos pos = be.getBlockPos();
Entry e = ENTRIES.get(pos);
if (be.getUrl().isEmpty() || !be.isAutoplay()) {
if (e != null) {
stop(pos);
}
return null;
}
if (e != null && e.url.equals(be.getUrl())) {
return e.id;
}
if (e != null) {
stop(pos);
}
if (be.getUrl().isEmpty() || !be.isAutoplay()) {
return null;
}
VideoBackend backend = WatermediaProbe.isAvailable() ? new WatermediaBackend() : new JavaCvBackend();
backend.play(be.getUrl(), be.isLoop());
backend.setVolume(be.isMuted() ? 0F : be.getVolume());
Entry created = new Entry(be.getUrl(), backend);
ENTRIES.put(pos, created);
return created.id;
@@ -56,10 +77,11 @@ public final class VideoPlayback {
}
public static void onConfigChanged(VideoAnchorBlockEntity be) {
Entry e = ENTRIES.get(be.getPos());
if (be == null) return;
Entry e = ENTRIES.get(be.getBlockPos());
if (e == null) return;
if (!e.url.equals(be.getUrl())) {
stop(be.getPos());
stop(be.getBlockPos());
return;
}
e.backend.setVolume(be.isMuted() ? 0F : be.getVolume());
@@ -67,7 +89,7 @@ public final class VideoPlayback {
/** Called every client tick to upload new frames into the GPU texture. */
public static void tick() {
if (MinecraftClient.getInstance() == null) return;
if (Minecraft.getInstance() == null) return;
Iterator<Map.Entry<BlockPos, Entry>> it = ENTRIES.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<BlockPos, Entry> me = it.next();
@@ -78,15 +100,15 @@ public final class VideoPlayback {
try {
e.upload(buf);
} catch (Throwable t) {
// texture upload errors shouldn't kill the client; drop this entry
VideoPlayerMod.LOG.warn("[{}] texture upload failed: {}", VideoPlayerMod.MOD_ID, t.toString());
e.close();
it.remove();
}
}
}
public static java.util.Set<BlockPos> activePositions() {
return new java.util.HashSet<>(ENTRIES.keySet());
public static Set<BlockPos> activePositions() {
return new HashSet<>(ENTRIES.keySet());
}
public static void setGain(BlockPos pos, float gain) {
@@ -99,51 +121,83 @@ public final class VideoPlayback {
ENTRIES.clear();
}
/** Per-anchor playback state. */
private static final class Entry {
final String url;
final VideoBackend backend;
final Identifier id;
NativeImageBackedTexture texture;
DynamicTexture texture;
int texW = 0, texH = 0;
boolean registered = false;
Entry(String url, VideoBackend backend) {
this.url = url;
this.backend = backend;
this.id = Identifier.of("video_player", "dynamic/" + Integer.toHexString(System.identityHashCode(this)));
String tag = Integer.toHexString(System.identityHashCode(this));
this.id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "dynamic/" + tag);
ensureTexture(PLACEHOLDER_SIZE, PLACEHOLDER_SIZE, true);
}
/** Allocate or resize the dynamic texture, registering it on first allocation. */
private void ensureTexture(int w, int h, boolean fillPlaceholder) {
if (texture != null && w == texW && h == texH) return;
if (texture != null) texture.close();
NativeImage img = new NativeImage(w, h, false);
if (fillPlaceholder) {
fillPlaceholder(img, w, h);
}
texture = new DynamicTexture(() -> "video_player:" + id, img);
texW = w;
texH = h;
Minecraft mc = Minecraft.getInstance();
if (mc != null) {
mc.getTextureManager().register(id, texture);
registered = true;
}
texture.upload();
}
/** Dark gray surface with a thin border — visible "screen" until first frame arrives. */
private static void fillPlaceholder(NativeImage img, int w, int h) {
// ABGR int. 0xAABBGGRR.
int body = 0xFF202020; // dark gray
int border = 0xFF505050;
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
boolean isEdge = (x == 0 || y == 0 || x == w - 1 || y == h - 1);
img.setPixelABGR(x, y, isEdge ? border : body);
}
}
}
/** Copy an incoming RGBA byte buffer into the texture, resizing if dimensions changed. */
void upload(ByteBuffer rgba) {
int w = backend.videoWidth();
int h = backend.videoHeight();
if (w <= 0 || h <= 0) return;
if (texture == null || w != texW || h != texH) {
if (texture != null) texture.close();
NativeImage img = new NativeImage(NativeImage.Format.RGBA, w, h, false);
texture = new NativeImageBackedTexture(() -> "video_player_dyn", img);
MinecraftClient.getInstance().getTextureManager().registerTexture(id, texture);
texW = w; texH = h;
}
NativeImage img = texture.getImage();
ensureTexture(w, h, false);
NativeImage img = texture.getPixels();
if (img == null) return;
// copy buf → image pixels (RGBA bytes, native order)
int pixels = w * h;
for (int i = 0; i < pixels; i++) {
int r = rgba.get() & 0xFF;
int g = rgba.get() & 0xFF;
int b = rgba.get() & 0xFF;
int a = rgba.get() & 0xFF;
int argb = (a << 24) | (r << 16) | (g << 8) | b;
img.setColorArgb(i % w, i / w, argb);
int abgr = (a << 24) | (b << 16) | (g << 8) | r;
img.setPixelABGR(i % w, i / w, abgr);
}
texture.upload();
}
void close() {
backend.close();
try { backend.close(); } catch (Throwable ignored) {}
if (texture != null) {
texture.close();
try { texture.close(); } catch (Throwable ignored) {}
texture = null;
}
// texture manager keeps the registration; the texture itself is closed.
}
}
}

View File

@@ -1,91 +1,139 @@
package com.ejclaw.videoplayer.client.render;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.client.playback.VideoPlayback;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
import net.minecraft.client.render.RenderLayer;
import net.minecraft.client.render.VertexConsumer;
import net.minecraft.client.render.VertexConsumerProvider;
import net.minecraft.client.render.block.entity.BlockEntityRenderer;
import net.minecraft.client.render.block.entity.BlockEntityRendererFactory;
import net.minecraft.client.util.math.MatrixStack;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.Direction;
import net.minecraft.util.math.RotationAxis;
import net.minecraft.util.math.Vec3d;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
/** SPEC §5.2 — draws a width × height quad in front of the anchor, oriented by facing. */
/**
* Draws the video as a textured quad <em>on the surface of the block the user clicked</em>.
*
* <p>The anchor BE lives in the air block adjacent to the clicked wall. Its {@code facing}
* field is the surface normal of the wall (= the {@link Direction} the player clicked). The
* quad is rotated so its normal aligns with that direction and shifted so it sits flush against
* the wall surface, with a tiny outward offset to avoid z-fighting.
*/
@Environment(EnvType.CLIENT)
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity> {
public class VideoAnchorRenderer implements BlockEntityRenderer<VideoAnchorBlockEntity, VideoAnchorRenderer.State> {
/** Placeholder texture used until a frame is uploaded. */
private static final Identifier PLACEHOLDER =
Identifier.of(VideoPlayerMod.MOD_ID, "block/video_anchor");
/** Tiny outward offset so the quad doesn't z-fight with the wall. */
private static final float SURFACE_EPSILON = 0.001F;
public VideoAnchorRenderer(BlockEntityRendererFactory.Context ctx) {
// no-op — context kept for future symbol/lookup needs
public VideoAnchorRenderer(BlockEntityRendererProvider.Context ctx) {
// no-op
}
@Override
public void render(VideoAnchorBlockEntity be, float tickDelta, MatrixStack matrices,
VertexConsumerProvider vertices, int light, int overlay, Vec3d cam) {
Identifier tex = VideoPlayback.currentTexture(be.getPos());
Identifier bound = tex != null ? tex : PLACEHOLDER;
float w = be.getWidth();
float h = be.getHeight();
Direction facing = be.getFacing();
matrices.push();
// Center the quad above the anchor's top face, then rotate to facing.
matrices.translate(0.5, 1.01, 0.5);
float rot = facing.getAxis().isHorizontal()
? Direction.getHorizontalDegreesOrThrow(facing)
: 0F;
matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(-rot));
matrices.translate(-w / 2.0F, 0, 0);
VertexConsumer vc = vertices.getBuffer(RenderLayer.getEntityCutoutNoCull(bound));
Matrix4f mat = matrices.peek().getPositionMatrix();
// Two-sided quad in the XY plane at z=0
emit(vc, mat, 0, 0, 0, 0, 1, light, overlay);
emit(vc, mat, w, 0, 0, 1, 1, light, overlay);
emit(vc, mat, w, h, 0, 1, 0, light, overlay);
emit(vc, mat, 0, h, 0, 0, 0, light, overlay);
// back face (so the anchor is visible from behind too)
emit(vc, mat, 0, h, 0, 0, 0, light, overlay);
emit(vc, mat, w, h, 0, 1, 0, light, overlay);
emit(vc, mat, w, 0, 0, 1, 1, light, overlay);
emit(vc, mat, 0, 0, 0, 0, 1, light, overlay);
matrices.pop();
// Trigger playback startup lazily, on first frame the camera sees the BE.
VideoPlayback.getOrStart(be);
}
private static void emit(VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v,
int light, int overlay) {
vc.vertex(mat, x, y, z)
.color(255, 255, 255, 255)
.texture(u, v)
.overlay(overlay)
.light(light)
.normal(0F, 0F, 1F);
public State createRenderState() {
return new State();
}
@Override
public boolean rendersOutsideBoundingBox() {
public void extractRenderState(VideoAnchorBlockEntity be, State state, float partialTick,
Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumbling) {
BlockEntityRenderState.extractBase(be, state, crumbling);
state.width = be.getWidth();
state.height = be.getHeight();
state.facing = be.getFacing();
state.textureId = VideoPlayback.getOrStart(be);
}
@Override
public void submit(State state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) {
Identifier tex = state.textureId;
if (tex == null) return; // url empty or autoplay off — nothing to draw
final float w = state.width;
final float h = state.height;
final int light = state.lightCoords;
final Direction f = state.facing == null ? Direction.NORTH : state.facing;
pose.pushPose();
// 1) Move to the anchor block's center.
pose.translate(0.5F, 0.5F, 0.5F);
// 2) Rotate local +Z to align with the wall's outward normal.
applyFaceRotation(pose, f);
// 3) Place the quad's local origin (0,0) at the bottom-left corner of the anchor block's
// wall face, so the clicked block becomes the lower-left and the video grows up & right.
// Push it onto the wall surface (-0.5 along local +Z, the outward normal) plus a tiny
// epsilon outward so the quad doesn't z-fight with the wall.
pose.translate(-0.5F, -0.5F, -0.5F + SURFACE_EPSILON);
final Matrix4f mat = new Matrix4f(pose.last().pose());
RenderType rt = RenderTypes.entityCutout(tex);
collector.submitCustomGeometry(pose, rt, (poseUnused, vc) -> {
// Front face (visible from outside, looking back at the wall)
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
// Back face (in case the player ends up on the other side, e.g. clipping into the wall)
emit(vc, mat, 0F, h, 0F, 0F, 0F, light);
emit(vc, mat, w, h, 0F, 1F, 0F, light);
emit(vc, mat, w, 0F, 0F, 1F, 1F, light);
emit(vc, mat, 0F, 0F, 0F, 0F, 1F, light);
});
pose.popPose();
}
/**
* Rotate so local +Z (the quad's outward normal in its base orientation) becomes world {@code f},
* with local +X mapped to the natural "right" direction the player sees when looking at the face.
* Derivation: for each face {@code f}, pick the rotation that maps local +Z → f, +Y → world up
* (or a sensible substitute for top/bottom), so the quad lies flush against the wall, oriented
* the way the player intuits.
*/
private static void applyFaceRotation(PoseStack pose, Direction f) {
switch (f) {
case SOUTH -> { /* identity: local +Z = world +Z (south). +X = east, +Y = up. */ }
case NORTH -> pose.mulPose(Axis.YP.rotationDegrees(180F)); // +Z → -Z, +X → -X (west)
case EAST -> pose.mulPose(Axis.YP.rotationDegrees(90F)); // +Z → +X, +X → -Z (north)
case WEST -> pose.mulPose(Axis.YP.rotationDegrees(-90F)); // +Z → -X, +X → +Z (south)
case UP -> pose.mulPose(Axis.XP.rotationDegrees(-90F)); // +Z → +Y, +Y → -Z (north)
case DOWN -> pose.mulPose(Axis.XP.rotationDegrees(90F)); // +Z → -Y, +Y → +Z (south)
}
}
private static void emit(com.mojang.blaze3d.vertex.VertexConsumer vc, Matrix4f mat,
float x, float y, float z, float u, float v, int light) {
vc.addVertex(mat, x, y, z)
.setColor(255, 255, 255, 255)
.setUv(u, v)
.setOverlay(net.minecraft.client.renderer.texture.OverlayTexture.NO_OVERLAY)
.setLight(light)
.setNormal(0F, 0F, 1F);
}
@Override
public boolean shouldRenderOffScreen() {
return true;
}
@Override
public int getRenderDistance() {
public int getViewDistance() {
return 128;
}
/** Per-frame render data extracted from the BE. */
public static final class State extends BlockEntityRenderState {
public Identifier textureId;
public int width = 1;
public int height = 1;
public Direction facing = Direction.NORTH;
}
}

View File

@@ -3,42 +3,44 @@ package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.block.Blocks;
import net.minecraft.command.argument.BlockPosArgumentType;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text;
import net.minecraft.util.math.BlockPos;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/** SPEC §4.5.1 — {@code /videoDelete <pos>} */
public final class VideoDeleteCommand {
private VideoDeleteCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) {
dispatcher.register(register("videoDelete"));
dispatcher.register(register("videodelete"));
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoDelete"));
dispatcher.register(build("videodelete"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource>
register(String name) {
return CommandManager.literal(name)
.requires(s -> s.hasPermissionLevel(2))
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos())
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.executes(VideoDeleteCommand::run));
}
private static int run(com.mojang.brigadier.context.CommandContext<ServerCommandSource> ctx)
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource();
ServerWorld world = src.getWorld();
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos");
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) {
src.sendError(Text.literal("no anchor at that position"));
CommandSourceStack src = ctx.getSource();
ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity)) {
src.sendFailure(Component.literal("no anchor at that position"));
return 0;
}
world.setBlockState(pos, Blocks.AIR.getDefaultState());
src.sendFeedback(() -> Text.literal("anchor deleted at " + pos.toShortString()), true);
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
src.sendSuccess(() -> Component.literal("anchor deleted at " + pos.toShortString()), true);
return 1;
}
}

View File

@@ -7,55 +7,56 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.command.argument.BlockPosArgumentType;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text;
import net.minecraft.util.math.BlockPos;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
/** SPEC §4.5.1 — {@code /videoMute <pos> <on|off>} */
public final class VideoMuteCommand {
private VideoMuteCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) {
dispatcher.register(register("videoMute"));
dispatcher.register(register("videomute"));
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoMute"));
dispatcher.register(build("videomute"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource>
register(String name) {
return CommandManager.literal(name)
.requires(s -> s.hasPermissionLevel(2))
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos())
.then(CommandManager.argument("state", StringArgumentType.word())
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("state", StringArgumentType.word())
.executes(VideoMuteCommand::run)));
}
private static int run(com.mojang.brigadier.context.CommandContext<ServerCommandSource> ctx)
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource();
ServerWorld world = src.getWorld();
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos");
CommandSourceStack src = ctx.getSource();
ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
String state = StringArgumentType.getString(ctx, "state").toLowerCase();
boolean muted;
if ("on".equals(state) || "true".equals(state)) muted = true;
else if ("off".equals(state) || "false".equals(state)) muted = false;
else {
src.sendError(Text.literal("state must be on/off"));
src.sendFailure(Component.literal("state must be on/off"));
return 0;
}
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendError(Text.literal("no anchor at that position"));
if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendFailure(Component.literal("no anchor at that position"));
return 0;
}
be.setMuted(muted);
for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) {
for (ServerPlayer p : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, be.toNbt()));
}
final boolean mFinal = muted;
src.sendFeedback(() -> Text.literal("anchor " + (mFinal ? "muted" : "unmuted")), true);
src.sendSuccess(() -> Component.literal("anchor " + (mFinal ? "muted" : "unmuted")), true);
return 1;
}
}

View File

@@ -4,65 +4,66 @@ import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.net.SyncAnchorPayload;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.FloatArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.command.argument.BlockPosArgumentType;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.commands.arguments.coordinates.BlockPosArgument;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
/** SPEC §4.5.1 — {@code /videoPlace <pos> <facing> <width> <height> <url>} */
public final class VideoPlaceCommand {
private VideoPlaceCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) {
dispatcher.register(register("videoPlace"));
dispatcher.register(register("videoplace"));
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(build("videoPlace"));
dispatcher.register(build("videoplace"));
}
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<ServerCommandSource>
register(String name) {
return CommandManager.literal(name)
.requires(s -> s.hasPermissionLevel(2))
.then(CommandManager.argument("pos", BlockPosArgumentType.blockPos())
.then(CommandManager.argument("facing", StringArgumentType.word())
.then(CommandManager.argument("width", IntegerArgumentType.integer(1, 32))
.then(CommandManager.argument("height", IntegerArgumentType.integer(1, 32))
.then(CommandManager.argument("url", StringArgumentType.greedyString())
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack>
build(String name) {
return Commands.literal(name)
.requires(s -> s.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER))
.then(Commands.argument("pos", BlockPosArgument.blockPos())
.then(Commands.argument("facing", StringArgumentType.word())
.then(Commands.argument("width", IntegerArgumentType.integer(1, 32))
.then(Commands.argument("height", IntegerArgumentType.integer(1, 32))
.then(Commands.argument("url", StringArgumentType.greedyString())
.executes(VideoPlaceCommand::run))))));
}
private static int run(com.mojang.brigadier.context.CommandContext<ServerCommandSource> ctx)
private static int run(com.mojang.brigadier.context.CommandContext<CommandSourceStack> ctx)
throws CommandSyntaxException {
ServerCommandSource src = ctx.getSource();
ServerWorld world = src.getWorld();
BlockPos pos = BlockPosArgumentType.getLoadedBlockPos(ctx, "pos");
Direction facing = Direction.byId(StringArgumentType.getString(ctx, "facing"));
CommandSourceStack src = ctx.getSource();
ServerLevel level = src.getLevel();
BlockPos pos = BlockPosArgument.getLoadedBlockPos(ctx, "pos");
Direction facing = directionFromName(StringArgumentType.getString(ctx, "facing"));
if (facing == null) {
src.sendError(Text.literal("facing must be north/south/east/west/up/down"));
src.sendFailure(Component.literal("facing must be north/south/east/west/up/down"));
return 0;
}
int width = IntegerArgumentType.getInteger(ctx, "width");
int height = IntegerArgumentType.getInteger(ctx, "height");
String url = StringArgumentType.getString(ctx, "url").trim();
if (!url.isEmpty() && !(url.startsWith("http://") || url.startsWith("https://"))) {
src.sendError(Text.literal("url must be http:// or https:// (or empty)"));
src.sendFailure(Component.literal("url must be http:// or https:// (or empty)"));
return 0;
}
if (url.length() > 256) url = url.substring(0, 256);
world.setBlockState(pos, VideoPlayerBlocks.VIDEO_ANCHOR.getDefaultState());
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendError(Text.literal("failed to place anchor"));
level.setBlock(pos, VideoPlayerBlocks.VIDEO_ANCHOR.defaultBlockState(), Block.UPDATE_ALL);
if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
src.sendFailure(Component.literal("failed to place anchor"));
return 0;
}
be.setFacing(facing);
@@ -70,12 +71,20 @@ public final class VideoPlaceCommand {
be.setHeight(height);
be.setUrl(url);
NbtCompound nbt = be.toNbt();
for (ServerPlayerEntity p : PlayerLookup.tracking(world, pos)) {
CompoundTag nbt = be.toNbt();
for (ServerPlayer p : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(p, new SyncAnchorPayload(pos, nbt));
}
final BlockPos fp = pos;
src.sendFeedback(() -> Text.literal("anchor placed at " + fp.toShortString()), true);
src.sendSuccess(() -> Component.literal("anchor placed at " + fp.toShortString()), true);
return 1;
}
private static Direction directionFromName(String name) {
if (name == null) return null;
for (Direction d : Direction.values()) {
if (d.getSerializedName().equalsIgnoreCase(name)) return d;
}
return null;
}
}

View File

@@ -2,35 +2,35 @@ package com.ejclaw.videoplayer.command;
import com.ejclaw.videoplayer.registry.VideoPlayerItems;
import com.mojang.brigadier.CommandDispatcher;
import net.minecraft.item.ItemStack;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack;
public final class VideoStickCommand {
private VideoStickCommand() {}
public static void register(CommandDispatcher<ServerCommandSource> dispatcher) {
dispatcher.register(CommandManager.literal("videoStick")
public static void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("videoStick")
.executes(ctx -> run(ctx.getSource())));
// Lowercase alias — Brigadier is case-sensitive.
dispatcher.register(CommandManager.literal("videostick")
dispatcher.register(Commands.literal("videostick")
.executes(ctx -> run(ctx.getSource())));
}
private static int run(ServerCommandSource source) {
ServerPlayerEntity player = source.getPlayer();
private static int run(CommandSourceStack source) {
ServerPlayer player = source.getPlayer();
if (player == null) {
source.sendError(Text.literal("플레이어만 이 명령을 사용할 수 있습니다."));
source.sendFailure(Component.literal("플레이어만 이 명령을 사용할 수 있습니다."));
return 0;
}
ItemStack stack = new ItemStack(VideoPlayerItems.VIDEO_STICK);
boolean inserted = player.getInventory().insertStack(stack);
boolean inserted = player.getInventory().add(stack);
if (!inserted || !stack.isEmpty()) {
player.dropItem(stack, false);
player.drop(stack, false, false);
}
source.sendFeedback(() -> Text.literal("비디오 스틱을 지급했습니다."), false);
source.sendSuccess(() -> Component.literal("비디오 스틱을 지급했습니다."), false);
return 1;
}
}

View File

@@ -4,57 +4,68 @@ import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import com.ejclaw.videoplayer.net.OpenScreenPayload;
import com.ejclaw.videoplayer.registry.VideoPlayerBlocks;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Block;
import net.minecraft.block.BlockState;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.item.Item;
import net.minecraft.item.ItemUsageContext;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.ActionResult;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Direction;
import net.minecraft.world.World;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
/** SPEC §4.2 — right-click empty face → place anchor + open GUI. Right-click existing anchor → edit. */
/**
* Right-click a block's face with the video stick:
* <ul>
* <li>If a video anchor already exists in the adjacent air (= an anchor already drawn on this
* face), reopen its config GUI.</li>
* <li>Otherwise place an invisible anchor in the adjacent air block, set its facing to the
* clicked face direction (so the renderer draws the quad flush against this face), and
* open the config GUI.</li>
* </ul>
* The anchor block itself is invisible / non-collidable, so visually no new block appears —
* the video just shows up on the face the user clicked.
*/
public class VideoStickItem extends Item {
public VideoStickItem(Settings settings) {
super(settings);
public VideoStickItem(Properties properties) {
super(properties);
}
@Override
public ActionResult useOnBlock(ItemUsageContext ctx) {
World world = ctx.getWorld();
if (world.isClient) {
// server is authoritative; client just consumes the gesture
return ActionResult.SUCCESS;
public InteractionResult useOn(UseOnContext ctx) {
Level level = ctx.getLevel();
if (level.isClientSide()) {
return InteractionResult.SUCCESS;
}
if (!(level instanceof ServerLevel sl)) return InteractionResult.PASS;
Player player = ctx.getPlayer();
if (!(player instanceof ServerPlayer sp)) return InteractionResult.PASS;
BlockPos hit = ctx.getClickedPos();
Direction face = ctx.getClickedFace();
BlockPos anchorPos = hit.relative(face);
// Existing anchor on this face → reopen edit GUI.
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, existing.toNbt()));
return InteractionResult.SUCCESS;
}
ServerWorld sw = (ServerWorld) world;
PlayerEntity player = ctx.getPlayer();
if (!(player instanceof ServerPlayerEntity sp)) return ActionResult.PASS;
BlockPos hit = ctx.getBlockPos();
// Existing anchor → edit
if (sw.getBlockEntity(hit) instanceof VideoAnchorBlockEntity existing) {
ServerPlayNetworking.send(sp, new OpenScreenPayload(hit, existing.toNbt()));
return ActionResult.SUCCESS;
}
// Empty face → place anchor on top of the clicked face
Direction side = ctx.getSide();
BlockPos placeAt = hit.offset(side);
BlockState there = sw.getBlockState(placeAt);
if (!there.isReplaceable()) return ActionResult.PASS;
// Need an empty / replaceable space in front of the clicked face.
BlockState there = sl.getBlockState(anchorPos);
if (!there.canBeReplaced()) return InteractionResult.PASS;
Block anchor = VideoPlayerBlocks.VIDEO_ANCHOR;
sw.setBlockState(placeAt, anchor.getDefaultState());
sl.setBlock(anchorPos, anchor.defaultBlockState(), Block.UPDATE_ALL);
if (sw.getBlockEntity(placeAt) instanceof VideoAnchorBlockEntity be) {
be.setFacing(ctx.getHorizontalPlayerFacing().getOpposite());
ServerPlayNetworking.send(sp, new OpenScreenPayload(placeAt, be.toNbt()));
if (sl.getBlockEntity(anchorPos) instanceof VideoAnchorBlockEntity be) {
// Surface normal of the wall we're painting on points outward in the same direction
// as the face the player clicked.
be.setFacing(face);
ServerPlayNetworking.send(sp, new OpenScreenPayload(anchorPos, be.toNbt()));
}
return ActionResult.SUCCESS;
return InteractionResult.SUCCESS;
}
}

View File

@@ -1,24 +1,24 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.BlockPos;
import net.minecraft.core.BlockPos;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** C2S — delete an anchor from the VideoConfigScreen. */
public record DeleteAnchorPayload(BlockPos pos) implements CustomPayload {
public static final CustomPayload.Id<DeleteAnchorPayload> ID =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "delete_anchor"));
public record DeleteAnchorPayload(BlockPos pos) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<DeleteAnchorPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "delete_anchor"));
public static final PacketCodec<RegistryByteBuf, DeleteAnchorPayload> CODEC = PacketCodec.tuple(
BlockPos.PACKET_CODEC, DeleteAnchorPayload::pos,
public static final StreamCodec<RegistryFriendlyByteBuf, DeleteAnchorPayload> CODEC = StreamCodec.composite(
BlockPos.STREAM_CODEC, DeleteAnchorPayload::pos,
DeleteAnchorPayload::new
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.codec.PacketCodecs;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.BlockPos;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** S2C — open the VideoConfigScreen for an anchor on the client. */
public record OpenScreenPayload(BlockPos pos, NbtCompound data) implements CustomPayload {
public static final CustomPayload.Id<OpenScreenPayload> ID =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "open_screen"));
public record OpenScreenPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<OpenScreenPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "open_screen"));
public static final PacketCodec<RegistryByteBuf, OpenScreenPayload> CODEC = PacketCodec.tuple(
BlockPos.PACKET_CODEC, OpenScreenPayload::pos,
PacketCodecs.NBT_COMPOUND, OpenScreenPayload::data,
public static final StreamCodec<RegistryFriendlyByteBuf, OpenScreenPayload> CODEC = StreamCodec.composite(
BlockPos.STREAM_CODEC, OpenScreenPayload::pos,
ByteBufCodecs.COMPOUND_TAG, OpenScreenPayload::data,
OpenScreenPayload::new
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.codec.PacketCodecs;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.BlockPos;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** C2S — save edited config from VideoConfigScreen back to the server. */
public record SaveConfigPayload(BlockPos pos, NbtCompound data) implements CustomPayload {
public static final CustomPayload.Id<SaveConfigPayload> ID =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "save_config"));
public record SaveConfigPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<SaveConfigPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "save_config"));
public static final PacketCodec<RegistryByteBuf, SaveConfigPayload> CODEC = PacketCodec.tuple(
BlockPos.PACKET_CODEC, SaveConfigPayload::pos,
PacketCodecs.NBT_COMPOUND, SaveConfigPayload::data,
public static final StreamCodec<RegistryFriendlyByteBuf, SaveConfigPayload> CODEC = StreamCodec.composite(
BlockPos.STREAM_CODEC, SaveConfigPayload::pos,
ByteBufCodecs.COMPOUND_TAG, SaveConfigPayload::data,
SaveConfigPayload::new
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -1,27 +1,27 @@
package com.ejclaw.videoplayer.net;
import com.ejclaw.videoplayer.VideoPlayerMod;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.codec.PacketCodecs;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
import net.minecraft.util.math.BlockPos;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
/** S2C — push current anchor state (URL/dims/loop/volume/muted/autoplay) to clients in range. */
public record SyncAnchorPayload(BlockPos pos, NbtCompound data) implements CustomPayload {
public static final CustomPayload.Id<SyncAnchorPayload> ID =
new CustomPayload.Id<>(Identifier.of(VideoPlayerMod.MOD_ID, "sync_anchor"));
/** S2C — push current anchor state to clients tracking the chunk. */
public record SyncAnchorPayload(BlockPos pos, CompoundTag data) implements CustomPacketPayload {
public static final CustomPacketPayload.Type<SyncAnchorPayload> TYPE =
new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "sync_anchor"));
public static final PacketCodec<RegistryByteBuf, SyncAnchorPayload> CODEC = PacketCodec.tuple(
BlockPos.PACKET_CODEC, SyncAnchorPayload::pos,
PacketCodecs.NBT_COMPOUND, SyncAnchorPayload::data,
public static final StreamCodec<RegistryFriendlyByteBuf, SyncAnchorPayload> CODEC = StreamCodec.composite(
BlockPos.STREAM_CODEC, SyncAnchorPayload::pos,
ByteBufCodecs.COMPOUND_TAG, SyncAnchorPayload::data,
SyncAnchorPayload::new
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
public Type<? extends CustomPacketPayload> type() {
return TYPE;
}
}

View File

@@ -5,11 +5,13 @@ import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.PlayerLookup;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.block.Blocks;
import net.minecraft.nbt.NbtCompound;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.util.math.BlockPos;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.Permissions;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
/**
* Registers all four payload types and the two C2S server-side receivers.
@@ -20,72 +22,73 @@ public final class VideoPlayerNetwork {
public static void registerPayloadTypes() {
// S2C
PayloadTypeRegistry.playS2C().register(OpenScreenPayload.ID, OpenScreenPayload.CODEC);
PayloadTypeRegistry.playS2C().register(SyncAnchorPayload.ID, SyncAnchorPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(OpenScreenPayload.TYPE, OpenScreenPayload.CODEC);
PayloadTypeRegistry.clientboundPlay().register(SyncAnchorPayload.TYPE, SyncAnchorPayload.CODEC);
// C2S
PayloadTypeRegistry.playC2S().register(SaveConfigPayload.ID, SaveConfigPayload.CODEC);
PayloadTypeRegistry.playC2S().register(DeleteAnchorPayload.ID, DeleteAnchorPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(SaveConfigPayload.TYPE, SaveConfigPayload.CODEC);
PayloadTypeRegistry.serverboundPlay().register(DeleteAnchorPayload.TYPE, DeleteAnchorPayload.CODEC);
}
public static void registerServerReceivers() {
ServerPlayNetworking.registerGlobalReceiver(SaveConfigPayload.ID, (payload, context) -> {
ServerPlayerEntity player = context.player();
ServerWorld world = player.getWorld();
ServerPlayNetworking.registerGlobalReceiver(SaveConfigPayload.TYPE, (payload, context) -> {
ServerPlayer player = context.player();
ServerLevel level = player.level();
BlockPos pos = payload.pos();
context.server().execute(() -> handleSave(world, player, pos, payload.data()));
CompoundTag data = payload.data();
context.server().execute(() -> handleSave(level, player, pos, data));
});
ServerPlayNetworking.registerGlobalReceiver(DeleteAnchorPayload.ID, (payload, context) -> {
ServerPlayerEntity player = context.player();
ServerWorld world = player.getWorld();
ServerPlayNetworking.registerGlobalReceiver(DeleteAnchorPayload.TYPE, (payload, context) -> {
ServerPlayer player = context.player();
ServerLevel level = player.level();
BlockPos pos = payload.pos();
context.server().execute(() -> handleDelete(world, player, pos));
context.server().execute(() -> handleDelete(level, player, pos));
});
}
private static void handleSave(ServerWorld world, ServerPlayerEntity player, BlockPos pos, NbtCompound data) {
private static void handleSave(ServerLevel level, ServerPlayer player, BlockPos pos, CompoundTag data) {
if (!canModify(player, pos)) {
VideoPlayerMod.LOG.warn("[{}] {} attempted save without permission at {}",
VideoPlayerMod.MOD_ID, player.getName().getString(), pos);
return;
}
if (!(world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
if (!(level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) {
return;
}
be.applyFromNbt(sanitize(data));
// broadcast updated state to all players tracking the chunk
SyncAnchorPayload sync = new SyncAnchorPayload(pos, be.toNbt());
for (ServerPlayerEntity watcher : PlayerLookup.tracking(world, pos)) {
for (ServerPlayer watcher : PlayerLookup.tracking(level, pos)) {
ServerPlayNetworking.send(watcher, sync);
}
}
private static void handleDelete(ServerWorld world, ServerPlayerEntity player, BlockPos pos) {
private static void handleDelete(ServerLevel level, ServerPlayer player, BlockPos pos) {
if (!canModify(player, pos)) {
return;
}
if (world.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
world.setBlockState(pos, Blocks.AIR.getDefaultState());
if (level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity) {
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
/** Permission check: creative players or operators may modify anchors. */
public static boolean canModify(ServerPlayerEntity player, BlockPos pos) {
public static boolean canModify(ServerPlayer player, BlockPos pos) {
if (player.isCreative()) return true;
return player.hasPermissionLevel(2);
return player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER);
}
/** Strip out unexpected keys from C2S NBT before applying. */
private static NbtCompound sanitize(NbtCompound in) {
NbtCompound out = new NbtCompound();
out.putString("url", trimUrl(in.getString("url", "")));
out.putInt("width", clamp(in.getInt("width", 1), 1, 32));
out.putInt("height", clamp(in.getInt("height", 1), 1, 32));
out.putString("facing", in.getString("facing", "north"));
out.putBoolean("loop", in.getBoolean("loop", true));
out.putFloat("volume", Math.max(0F, Math.min(1F, in.getFloat("volume", 0.5F))));
out.putBoolean("muted", in.getBoolean("muted", false));
out.putBoolean("autoplay", in.getBoolean("autoplay", true));
private static CompoundTag sanitize(CompoundTag in) {
CompoundTag out = new CompoundTag();
out.putString("url", trimUrl(in.getStringOr("url", "")));
out.putInt("width", clamp(in.getIntOr("width", 1), 1, 32));
out.putInt("height", clamp(in.getIntOr("height", 1), 1, 32));
out.putString("facing", in.getStringOr("facing", "north"));
out.putBoolean("loop", in.getBooleanOr("loop", true));
out.putFloat("volume", Math.max(0F, Math.min(1F, in.getFloatOr("volume", 0.5F))));
out.putBoolean("muted", in.getBooleanOr("muted", false));
out.putBoolean("autoplay", in.getBooleanOr("autoplay", true));
return out;
}

View File

@@ -3,17 +3,17 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlockEntity;
import net.fabricmc.fabric.api.object.builder.v1.block.entity.FabricBlockEntityTypeBuilder;
import net.minecraft.block.entity.BlockEntityType;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.util.Identifier;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.Registry;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.block.entity.BlockEntityType;
public final class VideoPlayerBlockEntities {
private VideoPlayerBlockEntities() {}
public static final BlockEntityType<VideoAnchorBlockEntity> VIDEO_ANCHOR = Registry.register(
Registries.BLOCK_ENTITY_TYPE,
Identifier.of(VideoPlayerMod.MOD_ID, "video_anchor"),
BuiltInRegistries.BLOCK_ENTITY_TYPE,
Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, "video_anchor"),
FabricBlockEntityTypeBuilder.create(VideoAnchorBlockEntity::new, VideoPlayerBlocks.VIDEO_ANCHOR).build()
);

View File

@@ -2,20 +2,24 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.block.VideoAnchorBlock;
import net.minecraft.block.AbstractBlock;
import net.minecraft.block.Block;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.registry.RegistryKey;
import net.minecraft.registry.RegistryKeys;
import net.minecraft.util.Identifier;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.Registry;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockBehaviour;
public final class VideoPlayerBlocks {
private VideoPlayerBlocks() {}
public static final Block VIDEO_ANCHOR = register(
"video_anchor",
AbstractBlock.Settings.create().strength(1.0F).nonOpaque(),
BlockBehaviour.Properties.of()
.noCollision()
.noOcclusion()
.instabreak()
.replaceable()
.strength(0F),
VideoAnchorBlock::new
);
@@ -25,13 +29,13 @@ public final class VideoPlayerBlocks {
@FunctionalInterface
private interface BlockFactory<B extends Block> {
B create(AbstractBlock.Settings settings);
B create(BlockBehaviour.Properties properties);
}
private static <B extends Block> B register(String name, AbstractBlock.Settings settings, BlockFactory<B> factory) {
Identifier id = Identifier.of(VideoPlayerMod.MOD_ID, name);
RegistryKey<Block> key = RegistryKey.of(RegistryKeys.BLOCK, id);
B block = factory.create(settings.registryKey(key));
return Registry.register(Registries.BLOCK, key, block);
private static <B extends Block> B register(String name, BlockBehaviour.Properties props, BlockFactory<B> factory) {
Identifier id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, name);
ResourceKey<Block> key = ResourceKey.create(BuiltInRegistries.BLOCK.key(), id);
B block = factory.create(props.setId(key));
return Registry.register(BuiltInRegistries.BLOCK, key, block);
}
}

View File

@@ -2,34 +2,33 @@ package com.ejclaw.videoplayer.registry;
import com.ejclaw.videoplayer.VideoPlayerMod;
import com.ejclaw.videoplayer.item.VideoStickItem;
import net.minecraft.item.Item;
import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry;
import net.minecraft.registry.RegistryKey;
import net.minecraft.registry.RegistryKeys;
import net.minecraft.util.Identifier;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.Registry;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.item.Item;
public final class VideoPlayerItems {
private VideoPlayerItems() {}
public static final Item VIDEO_STICK = register(
"video_stick",
settings -> new VideoStickItem(settings.maxCount(1))
props -> new VideoStickItem(props.stacksTo(1))
);
public static void register() {
// For M1 we don't add to a vanilla item group; players get the stick via /videoStick.
// players get the stick via /videoStick command
}
@FunctionalInterface
private interface ItemFactory<I extends Item> {
I create(Item.Settings settings);
I create(Item.Properties properties);
}
private static <I extends Item> I register(String name, ItemFactory<I> factory) {
Identifier id = Identifier.of(VideoPlayerMod.MOD_ID, name);
RegistryKey<Item> key = RegistryKey.of(RegistryKeys.ITEM, id);
I item = factory.create(new Item.Settings().registryKey(key));
return Registry.register(Registries.ITEM, key, item);
Identifier id = Identifier.fromNamespaceAndPath(VideoPlayerMod.MOD_ID, name);
ResourceKey<Item> key = ResourceKey.create(BuiltInRegistries.ITEM.key(), id);
I item = factory.create(new Item.Properties().setId(key));
return Registry.register(BuiltInRegistries.ITEM, key, item);
}
}

View File

@@ -0,0 +1,6 @@
{
"model": {
"type": "minecraft:model",
"model": "video_player:item/video_stick"
}
}

View File

@@ -1,21 +1,5 @@
{
"parent": "block/block",
"textures": {
"all": "video_player:block/video_anchor",
"particle": "video_player:block/video_anchor"
},
"elements": [
{
"from": [0, 0, 0],
"to": [16, 2, 16],
"faces": {
"down": { "texture": "#all", "uv": [0, 0, 16, 16] },
"up": { "texture": "#all", "uv": [0, 0, 16, 16] },
"north": { "texture": "#all", "uv": [0, 0, 16, 2] },
"south": { "texture": "#all", "uv": [0, 0, 16, 2] },
"east": { "texture": "#all", "uv": [0, 0, 16, 2] },
"west": { "texture": "#all", "uv": [0, 0, 16, 2] }
}
}
]
}
}

View File

@@ -1,5 +1,5 @@
{
"parent": "item/generated",
"parent": "minecraft:item/generated",
"textures": {
"layer0": "video_player:item/video_stick"
}

View File

@@ -16,9 +16,9 @@
"client": [ "com.ejclaw.videoplayer.VideoPlayerClient" ]
},
"depends": {
"fabricloader": ">=0.16.0",
"fabricloader": ">=0.19.0",
"fabric-api": "*",
"minecraft": "${target_minecraft}",
"java": ">=21"
"java": ">=25"
}
}